Skip to content
This repository has been archived by the owner on Jan 30, 2023. It is now read-only.

Embedded app cannot complete OAuth process, and custom session storage documentation #64

Closed
jt274 opened this issue Mar 10, 2021 · 102 comments

Comments

@jt274
Copy link

jt274 commented Mar 10, 2021

Issue summary

App authentication broken after updating to the "new" way of authenticating embedded apps, according to the Shopify tutorial here: https://shopify.dev/tutorials/build-a-shopify-app-with-node-and-react

The previous tutorial produced a working app.

Error: Cannot complete OAuth process. No session found for the specified shop url:

Additionally, the tutorial utilizes MemorySessionStorage, and tells you not to use it. The following page provides a vague explanation of CustomSessionStorage, but does not give enough detail for a working example: https://github.com/Shopify/shopify-node-api/blob/main/docs/issues.md#notes-on-session-handling

The app in question produces the error with MemorySessionStorage.

Expected behavior

App should authenticate once, and store the session so no further authentication is required.

Tutorials should document a fully working CustomSessionStorage example, and explain how to properly access the shopOrigin parameter throughout the React app with cookies no longer active.

Actual behavior

App re-authenticates on almost every page refresh, or displays an error: Cannot complete OAuth process. No session found for the specified shop url:. This also may produce console errors for Graphql requests such as "invalid token < in JSON"

server.js file below:

require('isomorphic-fetch');
const dotenv = require('dotenv');
const Koa = require('koa');
const next = require('next');
const { default: createShopifyAuth } = require('@shopify/koa-shopify-auth');
const { verifyRequest } = require('@shopify/koa-shopify-auth');
const { default: Shopify, ApiVersion, SessionStorage } = require('@shopify/shopify-api');
const Router = require('koa-router');
const axios = require('axios').default;

dotenv.config();

Shopify.Context.initialize({
  API_KEY: process.env.SHOPIFY_API_KEY,
  API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
  SCOPES: process.env.SHOPIFY_API_SCOPES.split(","),
  HOST_NAME: process.env.SHOPIFY_APP_URL.replace(/https:\/\//, ""),
  API_VERSION: '2021-01',
  IS_EMBEDDED_APP: true,
  SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
});

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

const ACTIVE_SHOPIFY_SHOPS = {};

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

  server.use(
    createShopifyAuth({
      accessMode: 'online',
      async afterAuth(ctx) {
        const { shop, accessToken, scope } = ctx.state.shopify;
        ACTIVE_SHOPIFY_SHOPS[shop] = scope;

        //Store accessToken in database

        ctx.redirect(`/?shop=${shop}`);
      },
    }),
  );

  router.post("/graphql", verifyRequest(), async (ctx, next) => {
    await Shopify.Utils.graphqlProxy(ctx.req, ctx.res);
  });

  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) {
      ctx.redirect(`/auth?shop=${shop}`);
    } else {
      await handleRequest(ctx);
    }
  });

  router.get("(/_next/static/.*)", handleRequest);
  router.get("/_next/webpack-hmr", handleRequest);
  router.get("(.*)", verifyRequest(), handleRequest);

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

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

_app.js file below:

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

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

  render() {
    const app = this.context;

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

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

class MyApp extends App {
  render() {
    const { Component, pageProps, shopOrigin } = this.props;
    const config = { apiKey: process.env.NEXT_PUBLIC_SHOPIFY_API_KEY, shopOrigin: shopOrigin, forceRedirect: true };
    return (
      <React.Fragment>
        <Head>
          <title>My App</title>
          <meta charSet="utf-8" />
        </Head>
        <Provider config={config}>
          <ClientRouter />
          <AppProvider i18n={translations}>
            <MyProvider>
              <Component {...pageProps} />
            </MyProvider>
          </AppProvider>
        </Provider>
      </React.Fragment>
    );
  }
}

MyApp.getInitialProps = async ({ ctx }) => {
  return {
    shopOrigin: ctx.query.shop,
  }
}

export default MyApp;

@paulomarg
Copy link
Contributor

Hey @jt274, thanks for this. To answer your questions:

  1. I've pasted your code into a test app and it is working as expected. My steps were:
  • Replaced both files with yours and run the server
  • Go into my /admin/apps page on Shopify, click on my app
  • App redirects me to top level, performs OAuth
  • I come back to /admin/apps/my-app, page loads

Are you doing anything differently to run into the error you mention?

  1. We're actually in the process of adding a concrete example of CustomSessionStorage using Redis (Document CustomSessionStorage example shopify-api-js#129) to the documentation! As stated in the docs, the library provides the MemorySessionStorage class as a way to allow devs to quickly set up their apps, because we can't know which storage method is being used.

@jt274
Copy link
Author

jt274 commented Mar 10, 2021

@paulomarg Thanks - the documentation from that PR is the best so far.

I guess another question I have is about the session in general. In the previous Node/React tutorial, I don't remember anything about sessions. The libraries seemed to do all of it for you.

I am not familiar with Redis. In the example, using Redis, it would store the session data locally? I'm not seeing from the example how the session is originally generated and passed to the storeCallback, or how to get the id to pass to loadCallback or deleteCallback. And I'm using regular JS, not Typescript.

@paulomarg
Copy link
Contributor

In the previous iteration, we used koa-session to store the session, which simply stored the entire session in a cookie (as per their docs - note how they use a similar pattern for custom storage options). Unfortunately, because modern browsers are making it more difficult to use cookies within iframes, that option wouldn't work for embedded apps.

Our package still handles all of the logic behind sessions, and CustomSessionStorage is just a way for apps to tell us how they want to load / store / delete their sessions - the library handles creating the actual sessions and calling those methods, so apps don't have to worry about it.

Hope this helps clear things up for you and others who end up here from searches!

@jt274
Copy link
Author

jt274 commented Mar 10, 2021

@paulomarg I understand about the cookies, just not sure how the sessions all worked. Makes sense.

So using the CustomSessionStorage and Redis example, you don't have to pass anything to the callback functions (i.e. the session or id)?

I will try to get it working. If I do I'm willing to post the code.

@jt274
Copy link
Author

jt274 commented Mar 11, 2021

@paulomarg I am slowly getting somewhere. I've stored the session data to my database of choice, and am able to load it back. But when I do I get an error in the loadCallback method: InternalServerError: Expected return to be instanceof Session, but received instanceof Object.

If I do either one of these (where session is the returned session that's been loaded):

return new Session(session);

var newSession = new Session(session.id);
newSession = {...newSession, ...session};
return newSession;

I get this error: InternalServerError: Mismatched data types provided: string and undefined

The session is storing the id, shop, state, and isOnline parameters.

@ilugobayo
Copy link

@jt274 I think I'm at the same point as you.

I've implemented my CustomSessionStorage which is basically just inserting the session ID and the stringified session body in a database record.
So the storeCallback function works as expected, but whenever the app wants to load a session I get the same error InternalServerError: Expected return to be instanceof Session, but received instanceof Object.

This is currently my loadCallback function:

async loadCallback(id) {
  try {
    var reply = await dbGetHelper.getSessionForShop(id);
    if (reply) {
      return JSON.parse(reply);
    } else {
      return undefined;
    }
  } catch (err) {
    // throw errors, and handle them gracefully in your application
    throw new Error(err)
  }
 }

Maybe I'm doing it wrong after reading the following comment from @paulomarg here:
So you can just return JSON.parse from your loadSession callback and we'll convert it to a proper Session object for you.

I want to know if that's the right way to do it, or maybe I just misunderstood it.

Also, I've been having a hard time with the ACTIVE_SHOPIFY_SHOPS object, it's not clear to me whether the CustomSessionStorage replaces it, or you have to use it as well along with the session storage since the code in the tutorial includes the following comment:

// 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.

When and where should we set/update this object?

I have some other questions about custom routes (i.e. navigation elements in embedded app, can't display the pages on them) and verifyRequest, but I think I should find another issue more related to that.

@jt274
Copy link
Author

jt274 commented Mar 11, 2021

I was finally able to store and load a session during the auth with some messy code in the loadCallback:

var newSession = new Session(session.id)
    newSession.shop = session.shop;
    newSession.state = session.state;
    newSession.scope = session.scope;
    newSession.expires = session.expires; //initially undefined
    newSession.isOnline = session.isOnline; //initially undefined
    newSession.accessToken = session.accessToken; //initially undefined
    newSession.onlineAccessInfo = session.onlineAccessInfo; //initially undefined

    return newSession;

The Session object must first be initialized with only the session ID, then have the other parameters added (which are only shop, state and scope initially). After going through the auth, it created a second new session object of this format:

accessToken: "TOKEN"
expires: "2021-03-12T06:45:01.259Z"
id: "store-name.myshopify.com_12345678901"
isOnline: true
onlineAccessInfo: {

account_number: 1
associated_user: {

account_owner: true
collaborator: false
email: "[email protected]"
email_verified: true
first_name: "Bob"
id: 12345678901
last_name: "Smith"
locale: "en"

}

associated_user_scope: "write_script_tags,write_inventory,read_products"
expires_in: 86396
session: "LONG ID NUMBER"

}
scope: "write_script_tags,write_inventory,read_products"
shop: "store-name.myshopify.com"
state: "01234567890123"

I now continue to have graphql errors, such as Network error: Unexpected token B in JSON at position 0 or token <, etc.

@ilugobayo
Copy link

@jt274 are you using online or offline access token? I'm currently using offline tokens, should I use online tokens as well?

Also, that info about the owner, did you generate it or was autogenerated?

@jt274
Copy link
Author

jt274 commented Mar 11, 2021

@ilugobayo Can't find it now, but docs somewhere mentioned using online tokens for apps in the user facing admin, and offline were for a database backend that makes requests without the user interface.

When going through the Oauth, it initially creates a token in the database that only has the shop, state, id, and scope parameters. Then a couple seconds later in the Oauth process, it modifies the token to have all of that information above.

Now I'm figuring out why it appears I've created and loaded tokens but graphql requests no longer work.

@kato-takaomi-ams
Copy link

kato-takaomi-ams commented Mar 12, 2021

I also don't understand how to choose access-mode.
My understanding is as following table.is it correct?

!Edited!

  • My first understanding
access-mode online offline
user customer(use storefront) merchant(use shopify-admin or backend-system)
  • My current understanding
user/access-mode online offline sessionid
customer(use storefront) use not use uuidv4()
merchant(use shopify-admin) use not use ${shop}_${userId}
merchant(use backend-system)
e.g. external system cooperation
not use use offline_${shop}

@ilugobayo
Copy link

ilugobayo commented Mar 12, 2021

@jt274 it's really weird, in my case, the starting session object has only id, shop, state and isOnline, that's what is stored in the database at first, I modified my CustomSessionStorage to update the record in the database if a record already exists, this because at some point, after receiving the first session object, I receive another session object, now with id, shop, state, isOnline, accessToken and scope, but my loadCallback keeps loading the a session without the two last elements, I don't know why even though the record is actually updated in the database.

@ilugobayo
Copy link

@kato-takaomi-ams maybe that's where I'm failing? I'm only using the offline access mode, I'm not sure if I should be using online as well, I mean, I have an UI for the merchant, but most of what my app does requires offline access mode since its features involve several request to the Shopify Admin API

@paulomarg
Copy link
Contributor

Hey all, let me see if I can shed a bit of light here.

The storeCallback function is expected to update existing sessions. The reason behind it being called twice is that it stores the session once when OAuth begins, mostly so it can store the state - we check that when completing the OAuth process to make sure the request actually matches the OAuth process that we start, for extra security.

We've added support for loadCallback to return the result of a JSON.parse of a stored session so you don't have to build the object from scratch, but that change will only be available upon our next release.

As far as online vs offline sessions go, you can read up on how they work in our documentation. Essentially, online tokens are best suited for cases where users are directly interacting with your app, whereas offline tokens work better for background tasks, since they don't expire and are tied to the shop as a whole.

Hope this helps!

@ilugobayo
Copy link

@paulomarg thanks for this comment, I just have some questions about this:

  • Do you know why, even though I update the session in my database, the loadCallback keeps loading the "first version" of the session? Maybe I'm not promisifyng?

  • My app performs tasks where the merchant interacts but it also has various webhooks that perform tasks whenever these are triggered, should I be using both access modes? Or using offline covers both scenarios? If I should use both of them, can you illustrate me on how I should create/handle them?

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@ilugobayo I am not sure if this helps, but my app has the merchant interacting with it, as well as back-end things that happen with webhooks.

I am using the online session for the merchant interaction, but I've also stored the accessToken in the database under the shop name when the app is installed or reloaded. The back end looks this up and uses the accessToken for the back end requests to Shopify (no "session" from the package needed). Hope that makes sense.

@ilugobayo
Copy link

ilugobayo commented Mar 12, 2021

@jt274 thanks, I think using online access mode should be better then.

I actually do the same with the accessToken, whenever the app is installed, I store the accessToken associated to the shop domain (xxx.myshopify.com), my only concern is, wouldn't be a problem if by some reason, the accessToken is updated, then a webhook gets triggered just in time to try to use an invalid one? Do you think that scenario is possible or am I thinking way too much?

By the way, do you store the accessToken and the sessions in the same table? Also, do you store everything that comes with the session, do you do it as a stringified JSON as well?

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@ilugobayo I suppose that could happen? I have it update the accessToken whenever the app is loaded. I will try to look into how long the accessToken persists and when it would be updated. But I don't recall seeing anywhere that it does update - I thought it was similar to an API key.

I am using Firestore, and store the token/app information in one collection, and sessions in another. When I store the sessions, I put in everything that is given. Since Firestore already is JSON essentially, I just write the session object to it and the fields are mapped automatically. Configuring it to merge data, if it provides a new session a new document is created. If it provides a previous session the document is updated with any new data.

@ilugobayo
Copy link

@jt274 interesting, I think this is just getting clearer for me.

Now I just need to figure out why even if my storeCallback updates the session in my database, loadCallback keeps getting the "first phase" of the session; that is really weird, I don't know if I'm doing something wrong when storing/loading

Also, do you perform any checks on scope changes? If so, do you do it in afterAuth?

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@ilugobayo Have not worked on the scope changes yet, that also seems new. Still some smaller things like that to get done.

I think mine was doing something similar, but making sure the expires object was a Date seems to have fixed it so far. See here: #65

It does, however, create two sessions still. It creates one with a random id, populates all of it, then creates a second identical (minus slightly later expires timestamp) named store-name.myshopify.com_1234567890. It then appears to use the shop named session from then on. I'm not sure what the first session is all about.

@paulomarg
Copy link
Contributor

paulomarg commented Mar 12, 2021

Couple of comments:

  • @ilugobayo all of those callbacks should be promisified, so it could be that your app is moving forward before it actually updates the session.

  • Online and offline tokens work the same way in how they allow access to Shopify, with a few key differences for offline ones:

    • They never expire (so they're less secure)
    • Every user for that shop will have the same level of access, so you may want to avoid offline tokens if you need to limit access for certain users
    • Apps should not allow users to execute GraphQL mutations on behalf of your app (e.g. using the GraphQL proxy) with an offline token, as queries from offline tokens are expected to be coming directly from the app.
    • All of that said, it's still recommended that apps use online tokens for direct user interactions, and offline ones for background tasks.
  • The first session (random id) is the cookie-based OAuth session that happens at the top level (so App Bridge and therefore JWT are unavailable). When using embedded apps, the second session is the one that will be loaded from the JWT token on subsequent requests (since cookies are unavailable once the embedded app loads). Unfortunately, we need both as neither solution works for both OAuth and 'regular' usage.

@ilugobayo
Copy link

@jt274 Ok ok, this is just getting more and more interesting.

I will test all this with online access mode, since everything I was doing until now was using offline access mode.

Thank you so much for taking time to answer my questions, I might bother you once again if I find some more issues, I hope you can help me then once again.

@paulomarg

  • Yes, I think that's where I'm failing, I thought I was promisifying the callbacks, but it seems I'm not.
  • I'm moving to use online tokens then, it really seems like the better option here.
  • I see, that makes a lot of sense now; it's just weird that in offline access mode, the id is not random, the id is always offline_store-name.myshopify.com, the only difference is that at first, the session doesn't include the accessToken nor the scope, maybe that's why I was so confused.

Thanks for making it clearer!

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@paulomarg I think this issue is mostly resolved, but one more question.

Is the online/offline token you're referring to the accessToken parameter of the session object, and is its expire time the expires parameter in the session object? And if so, if your app uses a back end and user interface, should you use an offline token to make sure that the accessToken does not expire suddenly when the back end needs to make a request?

Because before all these auth changes, I simply stored the accessToken and had the back end use it whenever it made a request.

@ilugobayo
Copy link

@jt274 exactly my concern. That's why my first approach was to use offline access mode, what if the merchant doesn't open the app in more than the time it took the accessToken to expire, I know it's really hard for this to happen, but I see it as a possible scenario, then every request to the Admin API in the back end would fail, am I right?

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@ilugobayo I am not sure. It appears the onlineAccessInfo expires_in is 24 hours, but I think that is the session. Then there is the expires parameter, which seems to be a time stamp of the current time, that is not in the future... So I'm still unsure about the expiration of the actual accessToken.

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@ilugobayo
Copy link

ilugobayo commented Mar 12, 2021

@jt274 I was about to link you that. As you can see, the scenario where the accessToken becomes invalid could be possible. I was thinking if it was possible to use both modes though, but I think it would become too messy to handle.

@jt274
Copy link
Author

jt274 commented Mar 12, 2021

@ilugobayo I think offline is the way to go.

I've attempted to switch to offline and have new issues. :)

During auth, a session named offline_store-name.myshopify.com is created. It is then loaded with the callback four times...and then the callback tries a fifth time with a non-offline session name (no offline_ prefix). Of course this session is not found, and it throws the error Cannot proxy query. No session found.

I have set accessMode: 'offline' in createShopifyAuth and both instances in server.js of verifyRequest. But it is still looking at some point for an online token after reading the offline token four times in a row.

@ilugobayo
Copy link

@jt274 using offline access mode with MemorySessionStorage works as expected to me, you can try that to know if everything is ok, if that's the case then you might have something wrong in your callback functions.

My problem is what I mentioned above, that my loadCallback function doesn't get the updated session, probably because of what @paulomarg mentioned, haven't been able to fix it yet but once I do it, I think I won't have more issues in that regard.

Did you notice that the session id is the same from the start? Not getting a random id like in the online access mode.

By the way, I forgot to ask before, are you persisting the ACTIVE_SHOPIFY_SHOPS object as well? If so, how are you handling it?

@ilugobayo
Copy link

  • All of that said, it's still recommended that apps use online tokens for direct user interactions, and offline ones for background tasks.

@paulomarg does this mean it is possible to use both modes? If so, can you provide a brief example on how to implement it?

@nandezer
Copy link

Is anyone else not having the X-Shopify-API-Request-Failure-Reauthorize header not being returned with verifyRequest? When calling the /graphql route without an active session loaded, I am not getting the headers returned, but I get a 401 unauthorized response. I have verifyRequest({returnHeader: true}).

I believe it is causing some issues...

I am able to use graphql from my storefront without the ({returnHeader: true}.
The server.use(bodyParser()); in the app.prepare() was giving me problems but removing it seemed to do the trick.
I'm still struggling with the sessions so I don't currently do any checks if the session has expired or not though.

@ilugobayo
Copy link

@nandezer the app will work fine for a while, but if the session is invalid, it won't load nor properly perform requests to the server; though the customer could easily hit one of the other pages that are not "/", and it would automatically go to "/auth", since everything else requires a session, but that's completely awful UX.

@nandezer
Copy link

@nandezer the app will work fine for a while, but if the session is invalid, it won't load nor properly perform requests to the server; though the customer could easily hit one of the other pages that are not "/", and it would automatically go to "/auth", since everything else requires a session, but that's completely awful UX.

To be honest, I mainly need the offline tokens (for now).
My Shopify app only requires online tokens to be able to do one graphql mutation (draft a product). Everything else is done through webhooks, so far the vendors I work with only access the apps store front when installing it and don't do it again.
I need to allow that mutation to happen as it is in the Shopify guidelines that the vendors need to be able to deactivate the app from inside the app.

But I see how that can be a problem when I have to make it more interactive, thanks!.

@nandezer
Copy link

@nandezer sure, this is how I have it right now, first the shopifyAuths:

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

  // for the offline session token
  server.use(
    shopifyAuth({
      accessMode: 'offline',
      prefix: '/install',
      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)) {
          // performs the installation process, this is adding webhooks, script tags, & storing accessToken

          // redirect to '/auth' to generate the online session token
          ctx.redirect(`/auth?shop=${shop}`);
        } else if (installedStatus !== undefined && installedStatus !== null && installedStatus === 1) {
          await dbSetHelper.saveAccessTokenForShop(shop, scope, accessToken);
          
          // not sure here if I should redirect to '/auth' again or to '/?shop', maybe this isn't necessary anymore since I check the installed status in "/"
          //ctx.redirect(`/auth?shop=${shop}`);
        } else if (installedStatus === undefined || installedStatus === null) {
          logger.error(stringInject.default(logMessages.serverInstalledStatusUndefined, [shop]));
        }
      },
    })
  );

  // for the online session token
  server.use(
    shopifyAuth({
      async afterAuth(ctx) {
        const { shop } = ctx.state.shopify;
        const paidStatus = await dbGetHelper.getPaidStatus(shop);

        // since I use the billing process, I need to redirect the merchant to the billing page if the app has been just installed
        // still need to validate for cases when the charges were declined or the merchant just closed the page before choosing an 
        // option, I might need to check if a charge resource has been already generated for the shop, otherwise, the app would 
        // always redirect the merchant to the billing page if the charges haven't been accepted yet.
        if (paidStatus === 0) {
          const accessToken = await dbGetHelper.getAccessTokenforDomain(shop);
          const returnUrl = `https://${Shopify.Context.HOST_NAME}?shop=${shop}`;
          const subscriptionUrl = await getSubscriptionUrl(accessToken, shop, returnUrl);
          ctx.redirect(subscriptionUrl);
        } else if (paidStatus === 1) {
          // redirects the merchant to the app after generating the online session token
          ctx.redirect(`/?shop=${shop}`);
        }
      },
    })
  );

"/" route:

router.get("/", async (ctx) => {
    const sessionRetrieved = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res); // half the time returns undefined
    const shop = ctx.query.shop;

    // to handle the paid status if the redirection comes from the billing process
    if (ctx.url.includes("?charge_id=")) {
      await serverActionHelper.handlePaidStatus(ctx.query.shop);
    }
    
    // get the installed status of the app for that shop
    const installedStatus = await dbGetHelper.getInstalledStatus(shop);

    // This shop hasn't been seen yet, go through OAuth to create a session, same behavior as the ACTIVE_SHOPIFY_SHOPS object
    if (installedStatus === 0 || installedStatus === 2) {
      ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
      if (!sessionRetrieved) { // generates the OAuth loop because sessionRetrieved isn't consistent
        ctx.redirect(`/auth?shop=${shop}`);
      } else {
        await handleRequest(ctx);
      }
    }
  });

I also modified the "everythig else" endpoint:

router.get("(.*)", verifyRequest({accessMode: "online", authRoute: "/auth", fallbackRoute: "/install/auth"}), handleRequest); // Everything else must have sessions

I still need to perform some validations for the session retrieved (expiration & scopes), but given the apparent bug with the loadCurrentSession function I can't right now, anyway, with this at least I'm getting the three elements I'm supposed to get in my CustomSessionStorage, one session token for the offline access mode and two session tokens (a temporary one with a random ID and the one that lasts 24 hours with the shop name as part of the ID) for the online access mode.

I hope this helps you a bit!

Hey @ilugobayo with the initialisations to get both the online & offline tokens, have you come accross any problems with webhook triggers?
I am using heroku free services at the moment, so after 30min of inactivity the dynos are not active within my server and my app iddles, until the next event is triggered and they the app starts again. But when my webhook tries to execute I get Failed to process webhook: Error: No webhook is registered for topic carts/update.

I had understood that the point of registering those webhooks with offline tokens would avoid that problem (I initially had it after 24h, as my online token session expired).

Here is how my code looks like:

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 graphQLProxy from "@shopify/koa-shopify-graphql-proxy";
import Koa from "koa";
import next from "next";
import Router from "@koa/router";
//const bodyParser = require("koa-bodyparser");

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

const {   HOST } = process.env;

const { receiveWebhook, registerWebhook, } = require("@shopify/koa-shopify-webhooks");

const ACTIVE_SHOPIFY_SHOPS = {}; // I know I should store them on my database, I'm just working on the webhook problem for now.

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

  server.use(
    shopifyAuth({
      accessMode: "offline",
      prefix: "/install",
      async afterAuth(ctx) {
        console.log("after auth offline");
        const { shop, accessToken, scope } = ctx.state.shopify;
        ACTIVE_SHOPIFY_SHOPS[shop] = scope;

        //Register webhook for uninstalled app
        const registrationUninstalled = await Shopify.Webhooks.Registry.register({
            shop,
            accessToken,
            path: "/webhooks",
            topic: "APP_UNINSTALLED",
            webhookHandler: async (topic, shop, body) => {
              console.log("App uninstalled");
              const obj = JSON.parse(body);
              //...
              delete ACTIVE_SHOPIFY_SHOPS[shop];
            },
          });
        if (registrationUninstalled.success) {
          console.log("Successfully registered uninstalled app webhook!");
        } else {
          console.log("Failed to register uninstalled app webhook",registrationUninstalled.result);
        }

        //Register webhook for orders paid
        const registrationOrderPaid = await Shopify.Webhooks.Registry.register({
          shop,
          accessToken,
          path: "/webhooks",
          topic: "ORDERS_PAID",
          webhookHandler: (_topic, shop, body) => {
            console.log("received order paid webhook: ");
            const obj = JSON.parse(body);
            //...

          },
        });
        if (registrationOrderPaid.success) {
          console.log("Successfully registered Order Paid webhook!");
        } else {
          console.log("Failed to register Order Paid webhook",registrationOrderPaid.result);
        }

        //Register webhook for cart create
        const registrationCartCreate = await Shopify.Webhooks.Registry.register({
            shop,
            accessToken,
            path: "/webhooks",
            topic: "CARTS_CREATE",
            webhookHandler: async (_topic, shop, body) => {
              console.log('received cart create webhook: ');
              const obj = JSON.parse(body);
              //...
            },
          });
        if (registrationCartCreate.success) {
          console.log("Successfully registered cart create webhook!");
        } else {
          console.log( "Failed to register cart create webhook", registrationCartUpdate.result);
        }

        //Register webhook for cart update
        const registrationCartUpdate = await Shopify.Webhooks.Registry.register({
            shop,
            accessToken,
            path: "/webhooks",
            topic: "CARTS_UPDATE",
            webhookHandler: async (_topic, shop, body) => {
              console.log('received cart update webhook: ');
              const obj = JSON.parse(body);
              //...
            },
          });
        if (registrationCartUpdate.success) {
          console.log("Successfully registered cart update webhook!");
        } else {
          console.log("Failed to register cart update webhook",registrationCartUpdate.result);
        }
        // Redirect to app with shop parameter upon auth
        ctx.redirect(`/auth?shop=${shop}`);
      },
    })
  );
  server.use(
    shopifyAuth({
      async afterAuth(ctx) {
        console.log("after auth online");
        // Access token and shop available in ctx.state.shopify
        const { shop, accessToken, scope, expires } = ctx.state.shopify;

        ctx.cookies.set("shopOrigin", shop, {
          httpOnly: false,
          secure: true,
          sameSite: "none",
        });

         //....

        // Redirect to app with shop parameter upon auth
        ctx.redirect(`/?shop=${shop}`);
      },
    })
  );
  const webhook = receiveWebhook({ secret: Shopify.Context.API_SECRET_KEY });
  /*Receive webhook shop/redact*/
  router.post("/webhooks/shop/redact", webhook, (ctx) => {
    console.log("shop/redact webhook: ");
  });

  /*Receive webhook customers/data_request*/
  router.post("/webhooks/customers/data_request", webhook, (ctx) => {
    console.log("customers/data_request app webhook: ");
  });

  /*Receive webhook customers/redact*/
  router.post("/webhooks/customers/redact", webhook, (ctx) => {
    console.log("customers/redact webhook: ");
  });

  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;

    const sessionRetrieved = await Shopify.Utils.loadCurrentSession(ctx.req,ctx.res); // half the time returns undefined
    //console.log("Session retrieved");
    //console.log(sessionRetrieved);

    // This shop hasn't been seen yet, go through OAuth to create a session
    if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
      console.log("Shop hasn't been set");
      ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
      console.log("Shop has been set");
      /*if(!sessionRetrieved){ 
			console.log("Shop has no session");
			ctx.redirect(`/auth?shop=${shop}`);
		}
		else if (sessionRetrieved.expires === undefined || sessionRetrieved.expires < new Date()){
			
		}		
		else {
			console.log("Shop has session");
			await handleRequest(ctx);
		}*/
      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(), 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: "online",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 ${Shopify.Context.HOST_NAME}:${port}`);
  });
});

I'm not sure what is wrong, as the webhooks are created with an offline token, meaning they should be able to be called at any given time right?

PS: As I said on my previous comment, I need to be able to do graphql mutations to allow for my app to be deactivated, hence I need to have both online and offline tokens.

@nolandg
Copy link

nolandg commented Apr 20, 2021

loadCurrentSession() is returning undefined without ever calling my custom sessions storage loadSession callback. When I read the code for loadCurrentSession(), the only way for it to short circuit this way is if sessionId wasn't found.

The code for finding sessionId tells us that it looks at the authorization: Bearer header and then in cookies.

When loading say the / route with a browser, there is no authorization: Bearer header. I don't know what piece of code is ever supposed to set the cookie SESSION_COOKIE_NAME (that's the Shopify auth variable name). My app is hosted by my server at mydomain.toomanytlds and the browser blocks cross-domain cookies so the session cookies set on *.shopify.com won't work.

So does anyone know who is responsible for setting the session cookie on your app's domain? loadCurrentSession() will not work unless it gets an auth bearer header or cookie.

Why is this so hard and why aren't the examples working? Am I way off the beaten path? I just want the simplest way to get an app working that can make front-end Graphql calls.

@jt274
Copy link
Author

jt274 commented Apr 20, 2021

@nolandg I'm in the same place. I posted about that exact issue here: Shopify/shopify-api-js#162

I am able to actually load the session token on the front end using getSessionToken (even though the example does not show this is necessary, just to test). But for some reason the graphql calls are returning 401 status and no failure reauthorize header. Looking at the code, it should return a 403 status in that instance, so I'm not sure what's going on.

I removed the loadCurrentSession() and various checks from my server file to get the front end to load. It's not in the example app code, and I read in the docs somewhere the front end sets up the app bridge and generates a JWT token? So I figured I'd try letting the front end load, but the graphql calls fail even though I can load a session token.

Would @paulomarg have any input on this?

@ilugobayo
Copy link

@nandezer well, I'm not really sure about the GraphQL stuff since I don't use that myself, but here's how I have my webhooks registrations and their handlers, first the registrations inside the shopifyAuth for offline:

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 responsePaid = await Shopify.Webhooks.Registry.register({
  shop,
  accessToken,
  path: "/webhooks/orders-paid",
  topic: "ORDERS_PAID",
});

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

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

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

And the handlers, for which I'm using an endpoint for each one of them:

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);
        console.log(analyzeResult);
  
        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]));
      }
  });

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

    try {
      const updateResult = await serverActionHelper.updateTransaction(shopDomain, order, 'success');
      console.log(updateResult);

      if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
      } else if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code !== 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.error(stringInject.default(logMessages.serverOrderPaidErrorCode, [order.id, shopDomain, updateResult]));
      } else if (!updateResult || Object.keys(updateResult).length === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.info(stringInject.default(logMessages.serverOrderPaidUndefined, [order.id, shopDomain]));
      }
    } catch (err) {
      ctx.response.status = 200;
      ctx.response.message = 'success';
      ctx.body = {
        status: 'success'
      };
      logger.error(stringInject.default(logMessages.serverOrderPaidCatch, [order.id, shopDomain, err]));
    }
  });

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

    try {
      const updateResult = await serverActionHelper.updateTransaction(shopDomain, order, 'cancelled');
      console.log(updateResult);

      if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
      } else if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code !== 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.error(stringInject.default(logMessages.serverOrderCancelledErrorCode, [order.id, shopDomain, updateResult.reason_code]));
      } else if (!updateResult || Object.keys(updateResult).length === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.info(stringInject.default(logMessages.serverOrderCancelledUndefined, [order.id, shopDomain]));
      }
    } catch (err) {
      ctx.response.status = 200;
      ctx.response.message = 'success';
      ctx.body = {
        status: 'success'
      };
      logger.error(stringInject.default(logMessages.serverOrderCancelledCatch, [order.id, shopDomain, err]));
    }
  });

I honestly didn't want to handle everything in a single endpoint because it would be a mess to validate which webhook was triggered and what actions should be performed.

The only case in which I use const webhook = receiveWebhook({ secret: process.env.SHOPIFY_API_SECRET }); is for the GDPR webhooks.

Hope this helps you, let me know if you have more questions.

@ilugobayo
Copy link

ilugobayo commented Apr 20, 2021

I think I found a workaround for the issue when loadCurrentSession returns undefined, though I'm not sure if this would work for the GraphQL requests.

Since I'm using REST endpoints for all my requests to my app/server to request data and perform actions, I implemented authenticatedFetch which adds the authorization header with a session token to the request and then the endpoint in the server, using the verifyRequest() function, verifies the current session and if the session is valid, then the server proceeds to perform the actions in that endpoint, otherwise, nothing is done and I get a 403 error in the frontend.

At first I was testing only with online access sessions, and whenever the session was invalid for whatever reason, this can be that it was already expired, the scopes changed or the bug I discovered, my requests wouldn't work.

In the first two cases, it's pretty obvious why the session was invalid, and in theory, I needed to check/validate that in "/" to then decide whether or not I redirected to "/auth", something that I could never do because I was never able to retrieve an actual session (shop_customer as ID) with loadCurrentSession; in the third case, though the flow is pretty much the same, the session wasn't expired yet nor the scopes had changed, but the session that Shopify had after logging in again was different from the one stored in my session storage, and since Shopify never told the server to store a new session, even though my endpoints would successfully validate the session token sent from the frontend, the request to the Shopify API would fail because the access token I was trying to use was the one associated to the session in my storage session, since everytime I stored/updated a session, I stored/updated the access token, to persist the change.

After implementing both access modes simultaneously, online & offline, the third case "wasn't an issue anymore", because now my requests to the Shopify API would use the offline access token, which never changes; but the first two cases persisted, this time my app would try to retrieve the data from the server and since the session token is invalid, the endpoints would return a 403 to the authenticatedFetch, it would also try to go to "/auth" and the app would keep showing the skeleton page indefinitely.

This is when I came up with this workaround since I had no options left, this is my code in the frontend for when I perform a request to the server:

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

    const parsedURL = queryString.parseUrl(window.top[0].location.href);

    if (result.status === 200) {
      dataToReturn = await result.json();
    } else if (result.status === 403) {
      const redirect = Redirect.create(app);
      redirect.dispatch(Redirect.Action.REMOTE, {
        url: `${HOST}/auth?shop=${parsedURL.query.shop}`
      });
    }

    return dataToReturn;
  };

The line const sessionToken = await getSessionToken(app); was added because sometimes if I was for more than 1-2 minutes without performing anything in the app, whenever I pressed a button, the server would consider the JWT as not active, oddly enough the session would be successfully validated by verifyRequest(), but after decoding the token in the authorization header would give me that error, and since I use the latter to get shop origin, adding that line makes sure the JWT attached to the authorization header has just been requested, you could consider that line as just refreshing the JWT.

As you can see, there's the parsedURL constant, the URL that I used was window.top[0].location.href which includes the URL of my server in the first part and several query parameters after like hmac, host, session, shop, etc.
I tried to use window.location.ancestorOrigins[0] but it has compatibility issues with Firefox and IE; and window.parent.location but the problem with it was a cross-origin issue.

Finally, as you can see in the if block, if I get a 200 in the request to the server, it means everything was ok so I get the retrieved data, otherwise, if I get a 403, it means the authenticatedFetch failed because of invalid session and therefore, I redirect to "/auth" using my host URL and the shop value from the parsedURL.

This pretty much simulates the validation in the server, at least in my case, since I perform these request before displaying any data on my pages, so the redirection will happen when the merchant opens the app and the skeleton page is being displayed.

I know it's not the optimal solution, but it was the only way I found to finally complete the whole JWT authentication change in my app after being stuck with it for more than a month. I've been testing for both cases, when the expiration date is in the past and when the scopes are different, and in both of them the app redirects to "/auth". Also, all my features (UI, webhooks, App proxy) and everything seems to work as expected.

I hope this can help someone or maybe guide them to find a solution.

@nandezer
Copy link

@nandezer well, I'm not really sure about the GraphQL stuff since I don't use that myself, but here's how I have my webhooks registrations and their handlers, first the registrations inside the shopifyAuth for offline:

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 responsePaid = await Shopify.Webhooks.Registry.register({
  shop,
  accessToken,
  path: "/webhooks/orders-paid",
  topic: "ORDERS_PAID",
});

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

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

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

And the handlers, for which I'm using an endpoint for each one of them:

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);
        console.log(analyzeResult);
  
        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]));
      }
  });

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

    try {
      const updateResult = await serverActionHelper.updateTransaction(shopDomain, order, 'success');
      console.log(updateResult);

      if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
      } else if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code !== 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.error(stringInject.default(logMessages.serverOrderPaidErrorCode, [order.id, shopDomain, updateResult]));
      } else if (!updateResult || Object.keys(updateResult).length === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.info(stringInject.default(logMessages.serverOrderPaidUndefined, [order.id, shopDomain]));
      }
    } catch (err) {
      ctx.response.status = 200;
      ctx.response.message = 'success';
      ctx.body = {
        status: 'success'
      };
      logger.error(stringInject.default(logMessages.serverOrderPaidCatch, [order.id, shopDomain, err]));
    }
  });

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

    try {
      const updateResult = await serverActionHelper.updateTransaction(shopDomain, order, 'cancelled');
      console.log(updateResult);

      if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
      } else if (updateResult && Object.keys(updateResult).length > 0 && updateResult.reason_code !== 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.error(stringInject.default(logMessages.serverOrderCancelledErrorCode, [order.id, shopDomain, updateResult.reason_code]));
      } else if (!updateResult || Object.keys(updateResult).length === 0) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.info(stringInject.default(logMessages.serverOrderCancelledUndefined, [order.id, shopDomain]));
      }
    } catch (err) {
      ctx.response.status = 200;
      ctx.response.message = 'success';
      ctx.body = {
        status: 'success'
      };
      logger.error(stringInject.default(logMessages.serverOrderCancelledCatch, [order.id, shopDomain, err]));
    }
  });

I honestly didn't want to handle everything in a single endpoint because it would be a mess to validate which webhook was triggered and what actions should be performed.

The only case in which I use const webhook = receiveWebhook({ secret: process.env.SHOPIFY_API_SECRET }); is for the GDPR webhooks.

Hope this helps you, let me know if you have more questions.

How come the only place you use const webhook = receiveWebhook({ secret: process.env.SHOPIFY_API_SECRET }); is for the GDPR. You code worked, but the ctx.request.body; returns undefined when I'm running it, doing the "receiveWebhook" allows me to access the ctx.webhook.payload which contains the line_items for example. Is there any problem on using for all the endpoints though?

@ilugobayo
Copy link

@nandezer sorry for the late reply.

For me it works fine with no issues, my webhooks trigger and execute everything as expected. I saw in your previous post when you showed your code that you had this line //server.use(bodyParser()); I think the solution would be to uncomment that line, I don't know why you commented it though.

@nandezer
Copy link

@nandezer sorry for the late reply.

For me it works fine with no issues, my webhooks trigger and execute everything as expected. I saw in your previous post when you showed your code that you had this line //server.use(bodyParser()); I think the solution would be to uncomment that line, I don't know why you commented it though.

It was giving me problems when I had it actually. I think it on the frontend while doing the graphql query (I have done so many changes since then that I can rememeber at this point).
I guess my question is id there is any negative aspect in the long run of using const webhook = receiveWebhook({ secret: process.env.SHOPIFY_API_SECRET }); for all webhooks (since I got it all to work for now, or so it seems).

@eyal0s
Copy link

eyal0s commented Apr 27, 2021

@ilugobayo @jt274 thanks for sharing your process in this thread. It really helped me make progress 🙏

I decided to implement both an online and offline tokens following your blueprint. I'm trying to wrap my head around why I'm stuck at the first auth step because of an error. When trying to create an offline token I'm getting a 'InternalServerError: Mismatched data types provided: string and undefined' that originates from the load session callback. I noticed you were able to overcome it, can you please share your insights? Thx!

@MLCochrane
Copy link

A small comment for anyone else having trouble surrounding the session retrieval with, loadCurrentSession as there seems to be some common confusion.

When you're first going through OAuth and you're redirected outside of the admin area there are session cookies that are checked instead of the Authorization: Bearer header. This is why you may be able to see a valid session returned when first connecting. However, once the App Bridge forces a redirect BACK into the admin and into an iframe you no longer have access to those cookies and it will try to get the session id from the JWT. This is why loadCurrentSession will return undefined on any subsequent app page loads after OAuth.

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

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

      console.log(session); // undefined most of the time

      await handleRequest(ctx);
    }
  });

The token is not set automatically though, which is why the app-bridge-utils provides authenticatedFetch or getSessionToken. The App Bridge is what allows you to retrieve the token in your client and pass it back to your server for authenticated calls. The verifyRequest middleware also uses loadCurrentSession internally to protect your routes so assuming your client is using some method of getting the token from App Bridge then you should be good to go.

I don't believe it's expected that you would have access to the session in the first unauthenticated router.get('/', async(ctx) ...) route as the cookies aren't accessible most of the time and App Bridge isn't being used yet.

@jezsung
Copy link

jezsung commented Jun 5, 2021

Has anyone figured out how to handle the expiration of the online access token while a user is interacting with the app? Should I just redirect the user to OAuth flow to get a new online access token whenever a request to the Shopify server fails?

@chamathdev
Copy link

Also, I've been having a hard time with the ACTIVE_SHOPIFY_SHOPS object, it's not clear to me whether the CustomSessionStorage replaces it, or you have to use it as well along with the session storage since the code in the tutorial includes the following comment:

// 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.

When and where should we set/update this object?

I have some other questions about custom routes (i.e. navigation elements in embedded app, can't display the pages on them) and verifyRequest, but I think I should find another issue more related to that.

Can someone explain how to do this?

@ajaysaini99
Copy link

Can anyone help me in getting the permanent access token (access_token as per docs). Where and how in server.js, I have to make a post request to get the permanent access token.

@amitkumar3211
Copy link

Can anyone please help me with this I am new to Shopify app development?
when I m trying to install the app I m getting this error.
https://prnt.sc/u48ZAFczE_le

db.js file =>>>>>>>

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

var connection = mysql.createConnection({
host: "localhost",
user: "root",
password: "",
database: "shopify-app"
});

connection.connect();

let domain_id = '';
export var storeCallback = async (session) => {
try {
// console.log(session);
let data = session;
//console.log(data);

const payload = {...session}
console.log('payload here ');
console.log(payload);
data.onlineAccessInfo = JSON.stringify(session.isOnline);
//console.log(data.onlineAccessInfo);
if (data.id.indexOf(${data.shop}) > -1) {

        domain_id = data.id;
    }

    connection.query(`INSERT INTO shops (shop_url, session_id, domain_id, access_token, state, is_online, online_access_info, scope) VALUES ('${data.shop}','${data.id}','${domain_id}','${session.accessToken}','${data.state}','${data.isOnline}','${data.onlineAccessInfo}','${data.scope}') ON DUPLICATE KEY UPDATE access_token='${data.accessToken}', state='${data.state}', session_id='${data.id}', domain_id='${domain_id}', scope='${data.scope}', online_access_info='${data.onlineAccessInfo}' `, (error, result, fields) => {
        if (error) throw error
        console.log(result);
    })

return true;

} catch (err) {
    throw new Error(err);
}

}

// not used yet
export var loadCallback = async (id) => {
console.log(loadcallback id here ${id});
try {

    let newsession =  new Session(id);
    console.log(`access token  here ${newsession.accessToken}`);
    let query = new Promise((resolve, rej) => {

        connection.query(`SELECT * FROM shops where session_id = '${id}' OR domain_id = '${id}' LIMIT 1`, (error, result, fields) => {

            if (error) throw error;
            newsession.shop = result[0].shop_url;
            newsession.state = result[0].state;
            newsession.scope = result[0].scope;
            newsession.isOnline = result[0].isOnline == 'true' ? true : false;
            newsession.onlineAccessInfo = result[0].onlineAccessInfo;
            newsession.accessToken = result[0].accessToken;

           // const date = new date();
           // date.setDate(date.getDate() * 1);
        //  console.log(date);
        //     session.expires = date;


            if (session.expires && typeof newsession.expires == 'string') {
                session.expires = new Date(newsession.expires);
            }


        });
        resolve();


    });

    await query;
   // console.log(session);
    return newsession;

} catch (err) {
    throw new Error(err);
}

}
// not used yet
export const deleteCallback = async (id) => {

try {
    return false;
} catch (err) {
    throw new Error(err);
}

}

Index file =>>>>>>>>>>>>

// @ts-check
import { join } from "path";
import fs from "fs";
import express from "express";
import cookieParser from "cookie-parser";
import { Shopify, LATEST_API_VERSION } from "@shopify/shopify-api";

import applyAuthMiddleware from "./middleware/auth.js";
import verifyRequest from "./middleware/verify-request.js";
import { setupGDPRWebHooks } from "./gdpr.js";
import productCreator from "./helpers/product-creator.js";
import { BillingInterval } from "./helpers/ensure-billing.js";
import { AppInstallations } from "./app_installations.js";
import { storeCallback, loadCallback, deleteCallback } from './db/ouths.js';
const USE_ONLINE_TOKENS = false;
const TOP_LEVEL_OAUTH_COOKIE = "shopify_top_level_oauth";

const PORT = parseInt(process.env.BACKEND_PORT || process.env.PORT, 10);

// TODO: There should be provided by env vars
const DEV_INDEX_PATH = ${process.cwd()}/frontend/;
const PROD_INDEX_PATH = ${process.cwd()}/frontend/dist/;

const DB_PATH = ${process.cwd()}/database.sqlite;

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?:///, ""),
HOST_SCHEME: process.env.HOST.split("://")[0],
API_VERSION: LATEST_API_VERSION,
IS_EMBEDDED_APP: true,
// This should be replaced with your preferred storage strategy
//SESSION_STORAGE: new Shopify.Session.SQLiteSessionStorage(DB_PATH),
SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
storeCallback,
loadCallback,
deleteCallback

)

});

Shopify.Webhooks.Registry.addHandler("APP_UNINSTALLED", {
path: "/api/webhooks",
webhookHandler: async (_topic, shop, _body) => {
await AppInstallations.delete(shop);
},
});

// The transactions with Shopify will always be marked as test transactions, unless NODE_ENV is production.
// See the ensureBilling helper to learn more about billing in this template.
const BILLING_SETTINGS = {
required: false,
// This is an example configuration that would do a one-time charge for $5 (only USD is currently supported)
// chargeName: "My Shopify One-Time Charge",
// amount: 5.0,
// currencyCode: "USD",
// interval: BillingInterval.OneTime,
};

// This sets up the mandatory GDPR webhooks. You’ll need to fill in the endpoint
// in the “GDPR mandatory webhooks” section in the “App setup” tab, and customize
// the code when you store customer data.
//
// More details can be found on shopify.dev:
// https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks
setupGDPRWebHooks("/api/webhooks");

// export for test use only
export async function createServer(
root = process.cwd(),
isProd = process.env.NODE_ENV === "production",
billingSettings = BILLING_SETTINGS
) {
const app = express();
app.set("top-level-oauth-cookie", TOP_LEVEL_OAUTH_COOKIE);
app.set("use-online-tokens", USE_ONLINE_TOKENS);

app.use(cookieParser(Shopify.Context.API_SECRET_KEY));

applyAuthMiddleware(app, {
billing: billingSettings,
});

// Do not call app.use(express.json()) before processing webhooks with
// Shopify.Webhooks.Registry.process().
// See https://github.com/Shopify/shopify-api-node/blob/main/docs/usage/webhooks.md#note-regarding-use-of-body-parsers
// for more details.
app.post("/api/webhooks", async (req, res) => {
try {
await Shopify.Webhooks.Registry.process(req, res);
console.log(Webhook processed, returned status code 200);
} catch (e) {
console.log(Failed to process webhook: ${e.message});
if (!res.headersSent) {
res.status(500).send(e.message);
}
}
});

// All endpoints after this point will require an active session
app.use(
"/api/*",
verifyRequest(app, {
billing: billingSettings,
})
);

app.get("/api/products/count", async (req, res) => {
const session = await Shopify.Utils.loadCurrentSession(
req,
res,
app.get("use-online-tokens")
);
const { Product } = await import(
@shopify/shopify-api/dist/rest-resources/${Shopify.Context.API_VERSION}/index.js
);

const countData = await Product.count({ session });
res.status(200).send(countData);

});

app.get("/api/products/create", async (req, res) => {
const session = await Shopify.Utils.loadCurrentSession(
req,
res,
app.get("use-online-tokens")
);
let status = 200;
let error = null;

try {
  await productCreator(session);
} catch (e) {
  console.log(`Failed to process products/create: ${e.message}`);
  status = 500;
  error = e.message;
}
res.status(status).send({ success: status === 200, error });

});

// All endpoints after this point will have access to a request.body
// attribute, as a result of the express.json() middleware
app.use(express.json());

app.use((req, res, next) => {
const shop = Shopify.Utils.sanitizeShop(req.query.shop);
if (Shopify.Context.IS_EMBEDDED_APP && shop) {
res.setHeader(
"Content-Security-Policy",
frame-ancestors https://${encodeURIComponent( shop )} https://admin.shopify.com;
);
} else {
res.setHeader("Content-Security-Policy", frame-ancestors 'none';);
}
next();
});

if (isProd) {
const compression = await import("compression").then(
({ default: fn }) => fn
);
const serveStatic = await import("serve-static").then(
({ default: fn }) => fn
);
app.use(compression());
app.use(serveStatic(PROD_INDEX_PATH, { index: false }));
}

app.use("/*", async (req, res, next) => {
const shop = Shopify.Utils.sanitizeShop(req.query.shop);
if (!shop) {
res.status(500);
return res.send("No shop provided");
}

const appInstalled = await AppInstallations.includes(shop);

if (shop && !appInstalled) {
  res.redirect(`/api/auth?shop=${encodeURIComponent(shop)}`);
} else {
  const fs = await import("fs");
  const fallbackFile = join(
    isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH,
    "index.html"
  );
  res
    .status(200)
    .set("Content-Type", "text/html")
    .send(fs.readFileSync(fallbackFile));
}

});

return { app };
}

createServer().then(({ app }) => app.listen(PORT));

@github-actions
Copy link

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 github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 30, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests