koa-shopify-auth icon indicating copy to clipboard operation
koa-shopify-auth copied to clipboard

What's the meaning of verifyRequest() and / route?

Open zirkelc opened this issue 4 years ago • 7 comments

I'm trying to build a non-embedded custom app with offline access and struggle with the migration from cookie-based to session-based auth.

I followed the example app (https://github.com/Shopify/koa-shopify-auth#example-app) but can't make any sense of the actual auth flow and the reason for the routes:

  1. Click on app install link (generated via Partner Dashboard) -> redirect to App on / route: GET /hmac=xxx&shop=shop.myshopify.com&timestamp=1628157491

  2. Check if app is already installed for shop param and redirect to /auth route: GET /install/auth?shop=shop.myshopify.com

  3. Redirect to /auth/inline route GET /install/auth/inline?shop=monte-stivo.myshopify.com

  4. Redirect to Shopify to confirm install of app https://shop.myshopify.com/admin/oauth/request_grant?client_id=xxx

  5. Shopify calls callback route /install/auth/callback GET /install/auth/callback?code=xxx&hmac=xxx&host=xxx&shop=shop.myshopify.com&state=596690710300988&timestamp=1628157499

  6. afterAuth method will be invoked to retrieve Access Token and register Webhooks; redirect to route /?shop=shop

  7. Route / checks if Shop has been installed and loads app skeleton

After successful auth, I would expect that any GET request to a route like /test goes through verifyRequest() and would return the "secured" data. But the request to /test gets redirected to /install/auth, then /install/auth/inline, then to Shopify and back via callback to /install/auth/callback and we are back into our afterAuth handler. From there we redirect to / and we are right at the end of the Oauth flow again.

I would like to now what is the meaning of verifyRequest at all? And what is the reason we load an app skeleton after successful auth on the / route?

Here's my code for completeness:

export function shopifyAppServer(apiKey: string, secretKey: string, sessionTable: string): Koa {
  const sessionStore = new ShopifySessionStore(sessionTable);
	
  Shopify.Context.initialize({
    API_KEY: apiKey,
    API_SECRET_KEY: secretKey,
    SCOPES: [
      'read_products',
      'read_customers',
      'read_orders',
      'read_all_orders', 
      'read_shipping',
      'read_fulfillments',
    ],
    HOST_NAME: 'shopify.test.com',
    API_VERSION: ApiVersion.October20,
    IS_EMBEDDED_APP: false,
    SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
      sessionStore.storeCallback,
      sessionStore.loadCallback,
      sessionStore.deleteCallback,
    ),
  });

  const handleAppUninstalled = async (topic: string, shop: string, body: string) => {
    await Shopify.Utils.deleteOfflineSession(shop);
  };
  Shopify.Webhooks.Registry.webhookRegistry.push({
    path: '/webhooks',
    topic: 'APP_UNINSTALLED',
    webhookHandler: handleAppUninstalled,
  });

  const app = new Koa();
  const router = new Router();

  app.use(logger());
  app.keys = [Shopify.Context.API_SECRET_KEY];

  app.use(
    shopifyAuth({
      accessMode: 'offline',
      prefix: '/install',
      async afterAuth(ctx) {
        const { shop, accessToken } = ctx.state.shopify;
        console.log('We did it!', accessToken);

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

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

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

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

    if (!shop) {
      ctx.body = 'Shop query parameter missing';
      return;
    }

    const installed = await sessionStore.isInstalled(shop);

    if (!installed) {
      ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
      ctx.body = '🎉';
    }
  });

  router.post('/webhooks', async (ctx) => {
    console.log('webhook', 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}`);
    }
  });

  // Everything else must have sessions
  router.get(
    '(.*)',
    verifyRequest({
      authRoute: '/install/auth',
      fallbackRoute: '/install/auth',
      accessMode: 'offline',
    }),
    async (ctx) => {
      // this code is never reached
      console.log('verifyRequest');
      console.log(ctx);
    },
  );

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

  return app;
}

zirkelc avatar Aug 05 '21 12:08 zirkelc

You need to perform any routing on the client side. Any page that lands on /:someroute needs make use of the same logic that loads an app skeleton and not perform any verifyRequest calls.

Then once your app has loaded, all routing should be done on the client and load any sensitive data using calls to endpoints that do perform verifyRequest. I use NextJS API routes for this.

// server.ts
  router.all(
    "(/api.*)",
    verifyRequest({ accessMode: "online", returnHeader: true }),
    handleRequest
  );

  // We perform client side authorization
  // Make sure to never expose any secrets during SSR and only show a Skeleton page.
  router.get("(.*)", async (ctx) => {
    const { shop } = ctx.query;
    const authenticatedShop = shop && (await ShopStorage.findShop(shop));

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

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

I use the AppBridge react components to perform routing on the client side:

// ClientRouter.js
import React from "react";
import { withRouter } from "next/router";
import { ClientRouter as AppBridgeClientRouter } from "@shopify/app-bridge-react";

function ClientRouter(props) {
  const { router } = props;
  return <AppBridgeClientRouter history={router} />;
}

export default withRouter(ClientRouter);

And I make sure that Polaris uses the ClientRouter in every case:

// LinkComponent.js
import Link from "next/link";

const LinkComponent = ({ children, url, ...props }) => {
  return (
    <Link href={url} passHref>
      <a {...props}>{children}</a>
    </Link>
  );
};

export default LinkComponent;

And I use these two components in my pages/_app.js:

// pages/_app.js
function MyProvider(props) {
  const app = useAppBridge();

  const client = new ApolloClient({
    link: new HttpLink({
      fetch: userLoggedInFetch(app),
      fetchOptions: {
        credentials: "include",
      },
    }),
    cache: new InMemoryCache(),
  });

  const Component = props.Component;

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

class MyApp extends App {
  render() {
    const { Component, pageProps, host, shop } = this.props;
    return (
      <AppProvider i18n={translations} linkComponent={LinkComponent}>
        <Provider
          config={{
            apiKey: API_KEY,
            forceRedirect: true,
            host,
          }}
        >
          <ClientRouter />
          <MyProvider Component={Component} host={host} {...pageProps} />
        </Provider>
      </AppProvider>
    );
  }
}

MyApp.getInitialProps = async ({ ctx }) => {
  const { host, shop } = ctx.query;

  return {
    host,
    shop,
  };
};

tolgap avatar Aug 20 '21 15:08 tolgap

Thanks for your comment!

I'm building a non-embedded app (without AppBridge) and use the authentication only to retrieve an offline access token. So do I need verifyRequest() at all?

zirkelc avatar Aug 26 '21 08:08 zirkelc

At some point you will use that offline access token in one of your requests to retrieve things from Shopify (or maybe your own local database as well?), so yes you will need verifyRequest().

tolgap avatar Aug 26 '21 09:08 tolgap

@tolgap I'm getting a 403 error when using Nextjs /api folder and verifyRequest. If I comment out the verifyRequest portion of the below code, everything works (feels like that's kind of the point). Have you experienced this?

 router.all(
    "(/api.*)",
    verifyRequest({ accessMode: "online", returnHeader: true }),
    handleRequest
  );

PurplePineapple123 avatar Dec 23 '21 00:12 PurplePineapple123

@PurplePineapple123 what does your request look like? Please provide the code for the API request that fails, at least, so we could see what is going wrong.

tolgap avatar Dec 23 '21 11:12 tolgap

@tolgap See below. I create an axios instance for an authenticated request getting the session token from app bridge. Then I pass that instance as a prop and use it in my component.

/pages/_app.js

function MyProvider(props) {
  //console.log('this is run, axios request for current user?');
  const app = useAppBridge();

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

  // Create axios instance for authenticated request
  const authAxios = axios.create();
  // intercept all requests on this axios instance
  authAxios.interceptors.request.use(function (config) {
    return getSessionToken(app).then((token) => {

      console.log(token); //<= This successfully logs the token 

      // append your request headers with an authenticated token
      config.headers["Authorization"] = `Bearer ${token}`;
      return config;
    });
  });

  const Component = props.Component;

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

/pages/create Destructure authAxios prop and use in request:

  const handleSubmit = async (event) => {
    let res = await authAxios.get("/api/test");
    console.log(res.data);
  };

/api/test

export default function handler(req, res) {
  console.log(req.body);
  res.status(200).json({ name: 'John Doe' })
}

PurplePineapple123 avatar Dec 23 '21 15:12 PurplePineapple123

@zirkelc and @PurplePineapple123 please confirm have you go answer I am facing this same issue , I am creating the un-embedd app and I want my URL when the app loads to look like abc.com and not abc/hmac=sdfdsf&shop=sdfdf

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[bot] avatar Jan 30 '23 20:01 github-actions[bot]