Shopify / koa-shopify-auth

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

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

Closed zirkelc closed 1 year ago

zirkelc commented 3 years ago

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;
}
tolgap commented 3 years ago

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,
  };
};
zirkelc commented 3 years ago

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?

tolgap commented 3 years ago

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

PurplePineapple123 commented 2 years ago

@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
  );
tolgap commented 2 years ago

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

PurplePineapple123 commented 2 years ago

@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' })
}
shopify-development-code commented 2 years ago

@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

github-actions[bot] commented 1 year ago

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.