koa-shopify-auth
koa-shopify-auth copied to clipboard
Multi page server side rendered NextJS app fails to open other pages
Issue summary
I've managed my existing app to migrate to use sessions instead of cookies and initial index page loads fine. When I click on another menu items within embedded app, it re-initiates auth process and kicks ma back on index page.
Main Packages:
"@shopify/koa-shopify-auth": "^4.0.3"
"@shopify/app-bridge-utils": "^1.29.0"
"@koa/router": "^8.0.8"
"next": "^10.0.9"
"shopify-api-node": "^3.6.6"
Expected behavior
Should be able to visit other pages within app.
Actual behavior
When I click on other navigation items, does re-auth and kick me back to index page when I click on any navigation item
Code
server.js
import "@babel/polyfill";
import dotenv from "dotenv";
import "isomorphic-fetch";
import createShopifyAuth, { verifyRequest } from "@shopify/koa-shopify-auth";
import Shopify, { ApiVersion } from "@shopify/shopify-api";
import Koa from "koa";
import next from "next";
import Router from "koa-router";
import { MongoClient } from "mongodb";
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,
SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
// not including implementation to save some space
storeCallback,
loadCallback,
deleteCallback
)
});
// 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.
const ACTIVE_SHOPIFY_SHOPS = {};
MongoClient.connect(MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
}).then(async connection => {
console.info(`Successfully connected to Mongo`);
const mongodb = connection;
app.prepare().then(async () => {
const server = new Koa();
const router = new Router();
server.keys = [Shopify.Context.API_SECRET_KEY];
server.use(
createShopifyAuth({
apiKey: SHOPIFY_API_KEY,
secret: SHOPIFY_API_SECRET,
scopes: [SCOPES],
accessMode: "offline",
async afterAuth(ctx) {
// Access token and shop available in ctx.state.shopify
const { shop, accessToken, scope } = ctx.state.shopify;
ACTIVE_SHOPIFY_SHOPS[shop] = scope;
if (!response.success) {
console.log(
`Failed to register APP_UNINSTALLED webhook: ${response.result}`
);
}
// Redirect to app with shop parameter upon auth
ctx.redirect(`/?shop=${shop}`);
},
})
);
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;
// This shop hasn't been seen yet, go through OAuth to create a session
if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
ctx.redirect(`/auth?shop=${shop}`);
} else {
await handleRequest(ctx);
}
});
server.use(require("./api"));
router.get("(/_next/static/.*)", handleRequest); // Static content is clear
router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear
router.get("(.*)", verifyRequest({ accessMode: "offline" }), handleRequest); // Everything else must have sessions
server.use(router.allowedMethods());
server.use(router.routes());
server.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});
});
});
_app.js
import ApolloClient from "apollo-boost";
import { ApolloProvider } from "react-apollo";
import App from "next/app";
import { AppProvider } from "@shopify/polaris";
import { Provider, useAppBridge } from "@shopify/app-bridge-react";
import { authenticatedFetch } from "@shopify/app-bridge-utils";
import "@shopify/polaris/dist/styles.css";
import translations from "@shopify/polaris/locales/en.json";
function MyProvider(props) {
const app = useAppBridge();
const client = new ApolloClient({
fetch: authenticatedFetch(app),
fetchOptions: {
credentials: "include",
},
});
const Component = props.Component;
return (
<ApolloProvider client={client}>
<Component {...props} />
</ApolloProvider>
);
}
class MyApp extends App {
render() {
const { Component, pageProps, shopOrigin } = this.props;
return (
<AppProvider i18n={translations}>
<Provider
config={{
apiKey: API_KEY,
shopOrigin: shopOrigin,
forceRedirect: true,
}}
>
<MyProvider Component={Component} {...pageProps} />
</Provider>
</AppProvider>
);
}
}
MyApp.getInitialProps = async ({ ctx }) => {
return {
shopOrigin: ctx.query.shop,
API_KEY: process.env.SHOPIFY_API_KEY
};
};
export default MyApp;
Checklist
- [X] Please delete the labels section before submitting your issue
- [X] I have described this issue in a way that is actionable (if possible)
My problem is the same as yours, whenever I press one of the navigation items the app goes through the auth process all over again and displays the main page, if I add a route like this:
router.get("/index", handleRequest);
Then it works, but I think that's not the right way to do it since the request wouldn't be verified, and adding router.get("/index", verifyRequest({ accessMode: 'offline'}), handleRequest); has the same result as not having a route, the auth process happens all over again.
I tried to follow the tutorial in the Shopify docs, about how to build your user interface with Polaris, in which they tell you to add a ClientRouter component but adding it didn't solve the issue, I actually tested the code on that branch but the code doesn't seem to work, because whenever I press one of the navigation items, I get a Bad request
message in the embedded app and nothing happens, I don't know what could be the issue, I've been searching for a solution but no luck yet.
Confirm it! In my opinion, the problem is related to the auth token is lost after route to index page. need to save it and use it for following routing. But I still have no idea how.... any ideas?
I was able to replicate this on current https://github.com/Shopify/shopify-app-node with the help of Shopify CLI. If you aren't using Shopify CLI, you can simply clone the project and follow setup instructions.
Steps to reproduce:
- shopify create project jwt_test
- shopify serve
- Begin app installation on development store.
- App does authenticate well and loads index page fine. (It's damn slow btw as mentioned in https://github.com/Shopify/shopify-app-node/issues/584)
- Go back to codebase and create
pages/indexCopy.js
and just copy paste contents ofpages/index.js
in it. - Go to partners dashboard and add a navigation menu for newly created app.
- Navigate to app and then navigate to index copy
- You would see it would re-authenticates again and then kicks you back to index.js
Confirmed! App does authenticate well and loads index page fine, but other navigations results 'bad request'. Reload page results 'InternalServerError: Cannot complete OAuth process. Could not find an OAuth cookie for shop url:' and so on
@paulomarg could you please shed some light on this matter? It would be highly appreciated.
I also faced the same issue, when I navigate to other pages except index, it simply shows "bad request". This can be simply replicated by adding new pages on starter app created by the latest shopify cli. Please advise.
Quick update: This seems to be the issue around verifyRequest
middleware. Following doesn't fix the re-auth issue but at least loads navigated page after 10 seconds or so rather redirecting back to index.
Change
router.get("(.*)", verifyRequest({ accessMode: "offline" }), handleRequest);
to
router.get("(.*)", handleRequest);
Note: This isn't recommended solution.
@paulomarg would be great if you can look into this at your earliest convenience. Thanks a lot in advance :)
Hey folks! I think @pratiknikam is right that verifyRequest
would trigger that behaviour if your app is using server-rendered pages, because it would expect to find the JWT in the Authorization
header, if that request made it to the server - which won't happen for page navigation.
As mentioned in the tutorial, the ClientRouter
will prevent your app from making such requests by handling URL changes locally.
Note: I suspect the tutorial's code for that step might be using a slightly outdated version of koa-shopify-auth
. I'll make sure it's working as expected so you can use it as an example.
If you have to do a page reload when the URL changes, you should consider the following:
- That action should behave like the
/
endpoint, so it doesn't useverifyRequest
, but it should make sure to checkACTIVE_SHOPIFY_SHOPS
if the user can go straight to that page. - Not using
verifyRequest
means the endpoint is unauthenticated, so it should not load any sensitive information because it may be accessed by other users. You should load a page skeleton and useauthenticatedFetch
to grab any sensitive data. - You may need to make sure the
shop
param is in the URL
Update: I've tested the tutorial branch and the routing worked as expected. If you're trying it out, make sure you run npm install
and set up the .env
file.
@paulomarg thanks for additional helpful insights :) Adding ClientRouter
did help me here. Another question I have is around navigations which happen on client side.
If you look at following Polaris component. Whenever merchant clicks on "Create now", that throws 302 Found with Location being /auth route which then throws 400.
<CalloutCard
title="Create Element"
primaryAction={{
content: "Create now",
url: "/element/new"
}}
>
@pratiknikam can you share the code that worked for you? Last week I tried with the ClientRouter
component for various days to make it work but I just couldn't, guess I'm doing something wrong
the clientRouter in the tutorial code was originally, this does not affect in any way, problem does't solved! After install app succesfully loaded index page, following navigations by nav links impossible, anybody confirm it?
@valorloff I was originally missing ClientRouter
. After adding it like below, I can at least navigate to different pages. I'm still having an issue I mentioned just couple threads above though around client side navigation. URL doesn't change when I try to navigate client side with the help of Router or any Polaris component.
Updated _app.js
import ApolloClient from "apollo-boost";
import { ApolloProvider } from "react-apollo";
import App from "next/app";
import { AppProvider } from "@shopify/polaris";
import { Provider, useAppBridge } from "@shopify/app-bridge-react";
import { authenticatedFetch } from "@shopify/app-bridge-utils";
import "@shopify/polaris/dist/styles.css";
import translations from "@shopify/polaris/locales/en.json";
import ClientRouter from "../components/ClientRouter";
function MyProvider(props) {
const app = useAppBridge();
const client = new ApolloClient({
fetch: authenticatedFetch(app),
fetchOptions: {
credentials: "include",
},
});
const Component = props.Component;
return (
<ApolloProvider client={client}>
<Component {...props} />
</ApolloProvider>
);
}
class MyApp extends App {
render() {
const { Component, pageProps, shopOrigin } = this.props;
return (
<AppProvider i18n={translations}>
<Provider
config={{
apiKey: API_KEY,
shopOrigin: shopOrigin,
forceRedirect: true,
}}
>
<ClientRouter />
<MyProvider Component={Component} {...pageProps} />
</Provider>
</AppProvider>
);
}
}
MyApp.getInitialProps = async ({ ctx }) => {
return {
shopOrigin: ctx.query.shop,
API_KEY: process.env.SHOPIFY_API_KEY
};
};
export default MyApp;
@pratiknikam The 302 response from the first request means it was redirected to /auth
, most likely because of verifyRequest
.
Looking at your updated _app
file, I think you need to move your <ClientRouter />
line one level up, so it's between <AppProvider>
and <Provider>
.
Routing like redirect.dispatch(Redirect.Action.APP, {path: '/edit-collection', newContext: true});
works ok, but routing by nav links return bad request crash.
Do I understand correctly that the clientRouter makes Embedded App Navigation links client-side?
Should the server.js be involved when clicked Navigation links?
@paulomarg good catch! Now next issue I'm having is that page URL is not updating with page I'm navigating to. These are pages which are not set in app navigation but part of app. (Ex. Creating new block)
Agree with what @valorloff said around navigation. I'm seeing similar issue.
Doesn't work: (does redirect but doesn't update URL)
router.push(`/element/new`);
or
<CalloutCard
title="Create Element"
primaryAction={{
content: "Create now",
url: "/element/new"
}}
>
Does work: (does redirect and also updates URL)
redirect.dispatch(Redirect.Action.APP, {path: '/element/new', newContext: true});
@pratiknikam you seem to have solved a majority of the issue, could you share your ClientRouter
Component. I can't seem to figure out which packages you are using for the redirect
and for the Redirect
are you using these packages?
import redirect from "@shopify/koa-shopify-auth" import {Redirect} from "@shopify/app-bridge/actions"
My issue with "index" page solved thanks to this hint
But that's only half of the problem. Remain problem of devServer page reload during development process, which throws out
GET https://ngrokXXX/auth 400 (Bad Request)
You need two things, the ClientRouter
component and RoutePropagator
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);
import { useAppBridge, RoutePropagator as ShopifyRoutePropagator } from '@shopify/app-bridge-react';
const RoutePropagator = () => {
const router = useRouter();
const { route } = router;
const app = useAppBridge();
useEffect(() => {
app.subscribe(Redirect.ActionType.APP, ({ path }) => {
Router.push(path);
});
}, []);
return appBridge && route ? (
<ShopifyRoutePropagator location={route} />
) : null;
};
This has a tradeoff, you will lose the query
with hmac, shop and other params when you navigate to other pages but you can grab that from the session
object.
a[[.subscribe(Redirect.ActionType.APP, ({ path }) => { Router.push(path); });
Hi! Please, explain this section Maybe, appBridge is app and Router is router?
@valorloff A typo. Fixed.
Did you mean add RoutePropagator to _app.js?
<Provider config={config}>
<RoutePropagator />
<ClientRouter/>
<AppProvider i18n={translations}>
<MyProvider>
<Component {...pageProps} />
</MyProvider>
</AppProvider>
</Provider>
This works nice (including browser reload) until I make a change to the code and the devServer does restart (FastRefresh).
After devServer restart, in particular on '/' path, we get crush with GET https://ngrok/auth 400 (Bad Request)
error
@ivorpad did you mean to replace appBridge
by app
in
return appBridge && route ? ( <ShopifyRoutePropagator location={route} /> ) : null;
making it
return app && route ? ( <ShopifyRoutePropagator location={route} /> ) : null;
and where do you inject this component ?
in the _app.js
next to the <ClientRouter/>
?
Although i followed above suggestions, still I'm not able to do routing by using below app bridge action.
Redirect.create(app).dispatch(Redirect.Action.APP, { path: redirectionUrl, newContext: true });
The weird part is i can't see above dispatched action when i checked redux devtools for app bridge.
import {useEffect, useContext} from 'react';
import Router, { useRouter } from "next/router";
import { Context as AppBridgeContext } from "@shopify/app-bridge-react";
import { Redirect } from "@shopify/app-bridge/actions";
import { RoutePropagator as ShopifyRoutePropagator } from "@shopify/app-bridge-react";
const RoutePropagator = () => {
const router = useRouter();
const { route } = router;
const appBridge = useContext(AppBridgeContext);
useEffect(() => {
appBridge.subscribe(Redirect.ActionType.APP, ({ path }) => {
Router.push(path);
});
}, []);
return appBridge && route ? (
<ShopifyRoutePropagator location={route} app={appBridge} />
) : null;
}
export default RoutePropagator;
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);
import ApolloClient from "apollo-boost";
import { ApolloProvider } from "react-apollo";
import App from "next/app";
import { AppProvider } from "@shopify/polaris";
import { Provider, Context } from "@shopify/app-bridge-react";
import { Redirect } from '@shopify/app-bridge/actions';
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 RoutePropagator from '../components/RoutePropagator';
import { Provider as ReactReduxProvider } from 'react-redux';
import { useStore } from '../store/store';
function userLoggedInFetch(app) {
const fetchFunction = authenticatedFetch(app);
return async (uri, options) => {
const response = await fetchFunction(uri, options);
if (response.headers.get('X-Shopify-API-Request-Failure-Reauthorize') === '1') {
const authUrlHeader = response.headers.get('X-Shopify-API-Request-Failure-Reauthorize-Url');
const redirect = Redirect.create(app);
redirect.dispatch(Redirect.Action.APP, authUrlHeader || `/auth`);
return null;
}
return response;
};
}
class MyProvider extends React.Component {
static contextType = Context;
render() {
const app = this.context;
const client = new ApolloClient({
fetch: userLoggedInFetch(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: API_KEY, shopOrigin, forceRedirect: true };
const store = useStore({});
return (
<Provider config={config}>
<ClientRouter />
<AppProvider i18n={translations}>
<RoutePropagator />
<ReactReduxProvider store={store}>
<MyProvider>
<Component {...pageProps} />
</MyProvider>
</ReactReduxProvider>
</AppProvider>
</Provider>
);
}
}
MyApp.getInitialProps = async ({ ctx }) => {
return {
shopOrigin: ctx.query.shop,
API_KEY: process.env.SHOPIFY_API_KEY
};
};
export default MyApp;
I think the class components should be redone to functional for comfortable use hooks
@barisozdogan I decided to not use the built in shopify navigation and instead use my own polaris navigation. This is what the code looks like
import {useRouter} from 'next/router'
const router = useRouter()
Router.push({
pathname: [enter your url],
query: router.query
})
Well, I finally was able to make the tutorial code work, but now trying to implement that solution in my app gives me the next problem. Because I need to load some data from the server for which I use my custom routes, namely
On server.js:
router.get('/api/get-paid-status', async (ctx) => {
const referer = ctx.headers.referer;
const shopDomain = referer.substring(referer.indexOf('&shop=') + 6, referer.indexOf('×tamp'));
try {
const paidStatus = await dbGetHelper.getPaidStatus(shopDomain);
if (paidStatus !== undefined && paidStatus !== null) {
ctx.body = {
data: paidStatus
};
} else {
ctx.body = {
data: null
};
logger.error(stringInject.default(logMessages.serverPaidStatusUndefined, [shopDomain]));
}
} catch (err) {
ctx.body = {
data: null
};
logger.error(stringInject.default(logMessages.serverGetPaidStatusCatch, [shopDomain, err]))
}
});
Which I call from a page, in this case, a page that is not index, backfill.js
const paidStatus = await this.makeGetRequest('/api/bayonet/get-paid-status/');
The makeGetRequest is as follows to hit the route in the server
makeGetRequest = async (endpoint) => {
const result = await fetch(endpoint,
{
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
.then(resp => resp.json())
.then(json => {
return json;
});
return result;
};
If the app has been just loaded, it will show the index page with no issues and every request to the server will work as expected, the referer will contain the shopOrigin and everything, no issues at all, but if I try to use the navigation buttons, nothing will load, somehow I lose the authentication/session info?
I think this is where the problem lies, because checking the ngrok log and the console on Chrome, it tries to go to the auth page all over again, so my skeleton page never disappears, and as you can see, since I started to develop the app I had been using ctx.headers.referer to get the shopOrigin and then proceed to perform the corresponding tasks, but now with the ClientRouter
whenever I press on the navigation buttons, including the one linked to the '/'
endpoint, the referer only has the URL of my server and the endpoint, namely https://mylocalserver.ngrok.io/backfill
, no hmac, no shop, nothing.
If I try to go directly to the page from the address bar, not using the navigation buttons, I get InternalServerError: Cannot complete OAuth process. Could not find an OAuth cookie for shop url
I've been searching for so many days for a clear example on how to handle this, since the session storage has been already addressed and solved, this is the only part that I'm missing to make my app work again like it used to before the required changes for the app review.
I would endlessly appreciate anyone that could guide me on this, thank you.
@ilugobayo Did you upgrade to latest version of koa-shopify-auth: 4.1.2
? If so, please delete your iFrame cookies. I saw similar issue and clearing cookies did help me resolve it.
@pratiknikam Just upgraded the koa-shopify-auth
version and tried to test it using incognito mode, to avoid any old cookies stored, same situation. Do you make any requests to the server from any of your pages? If so, how did you manage to authenticate them?
I finally fixed my problem. Let me briefly explain here as well, as someone might be experiencing same situation.
In my case, the problem was came out during callback from twitter. I defined a route within server.js to be able to handle twitter callback request.
const handleRequest = async (ctx) => {
await handle(ctx.req, ctx.res);
ctx.respond = false;
ctx.res.statusCode = 200;
};
router.get("/oauth/callback/twitter", async (ctx) => {
const shop = ctx.cookies.get('shop');
ctx.redirect(`/auth?shop=${shop}`);
ctx.request.url = ctx.request.url + "&shop=" + shop
await handleRequest(ctx);
});
The problem for that route was to not checking whether auth completed successfully or not right after redirect. Because once oauth completed, it will be hit the same route again along the known parameters such as "hmac" and other stuff. So if i don't check hmac, it would be trying to re-authenticate again for an already authenticated request and it will end up with recursion. Corresponding nextjs page would be shown for this route at the end but shopify app bridge could not be initialized properly. This will lead to dispathing a Redirect action to the non created app bridge instance. When I check redux dev tools for the app bridge's state, I was not able to see dispatched redirect action which proofs my case.
In order to fix issue i changed my route as below;
router.get("/oauth/callback/twitter", async (ctx) => {
if (!ctx.request.query.hmac) {
const shop = ctx.cookies.get('shop');
ctx.redirect(`/auth?shop=${shop}`);
ctx.request.url = ctx.request.url + "&shop=" + shop
}
await handleRequest(ctx);
});
After this, I was able to monitor that app bridge created properly and Redirect started to working (Also observed successfull dispatched redirect action to the app bridge state within redux devtools) I hope it can help to your problems as well.
Thanks,
@barisozdogan did you test your app on Safari by turning off third party cookies? Reason I ask is because I see ctx.cookies.get
. If app doesn't have access to iFrame cookies, not sure where ctx
would store or fetch it from.