Focuses on API authentication, access scopes, and permission management.
Hey,
I've been stuck on this error for some time "Invalid OAuth callback". Took the day to try to debug it. I am using both offline and online tokens
The outcome of my debugging (Custom Session):
With custom session storage I found out that... it the state that is mismatched because of storeacallback called 2-4(inconsistently) times when first installed
here is an example if it getting called twice when I click test on development on Shopify partner (URL: /install/auth?shop=angiedesignsite.myshopify.com )
Console logged:
┃ -------------------- start storecallback-----------------------
┃ #####store session##### id={"id":"offline_angiedesignsite.myshopify.com","shop":"angiedesignsite.myshopify.com","state":"657591174910252","isOnline":false}
┃ -------------------- end storecallback-----------------------
┃ -------------------- start storecallback-----------------------
┃ #####store session##### id={"id":"offline_angiedesignsite.myshopify.com","shop":"angiedesignsite.myshopify.com","state":"012283685075465","isOnline":false}
┃ -------------------- end storecallback-----------------------
So this happens before adding app, my storecallback is called twice saving the correct state first (657591174910252) and then the wrong state (012283685075465).
After clicking add app the confirmation URL has the state (657591174910252) and it tries to loadcallback the first state "657591174910252" which fails because it saved the other state.
This only happens on the first install link clicked. After the first link has been clicked, It will not throw the oauth error. Meaning the state is called once after I have clicked on the install link
The outcome of debugging (Memory session):
Using memory session storage I get back map is undefined and throws the same error "Invalid Oauth Callback".
"TypeError: Cannot read property 'map' of undefined at new AuthScopes"
This is probably due to the storecallback inside memory being called multiple times
This only happens on the first install link clicked. After the first link has been clicked, It will not throw the oauth error. Meaning the state is called once after I have clicked on the install link
I feel like I'm missing something very small here. My redirect URL is set to both /auth and install/auth
here's my on server code:
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";
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.April21,
IS_EMBEDDED_APP: true,
SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
// SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
// storeCallback,
// loadCallback,
// deleteCallback
// ),
});
const ACTIVE_SHOPIFY_SHOPS = {};
app.prepare().then(async () => {
console.log("app prepare");
const server = new Koa();
const router = new Router();
server.keys = [Shopify.Context.API_SECRET_KEY];
// Using both "online" and "offline" Shopify API access modes
server.use(
createShopifyAuth({
accessMode: "offline",
prefix: "/install",
async afterAuth(ctx) {
// Access token and shop available in ctx.state.shopify
const { shop, accessToken, scope } = ctx.state.shopify;
ACTIVE_SHOPIFY_SHOPS[shop] = scope;
let response = await Shopify.Webhooks.Registry.register({
shop,
accessToken,
path: "/webhooks",
topic: "APP_UNINSTALLED",
webhookHandler: async (topic, shop, body) =>
delete ACTIVE_SHOPIFY_SHOPS[shop],
});
if (!response.success) {
console.log(
`Failed to register APP_UNINSTALLED webhook: ${response.result}`
);
}
ctx.redirect(`/auth?shop=${shop}`);
},
})
);
server.use(
createShopifyAuth({
async afterAuth(ctx) {
// Online access mode access token and shop available in ctx.state.shopify
const { shop } = ctx.state.shopify;
ctx.redirect(
`https://${shop}/admin/apps/${process.env.SHOPIFY_API_KEY}`
);
},
})
);
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) {
console.log("redirecting to auth?shop=", shop);
ctx.redirect(`/install/auth?shop=${shop}`);
} else {
// const session = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res);
await handleRequest(ctx);
}
});
//create special route for install
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({ returnHeader: true }),
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({
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 http://localhost:${port}`);
});
});
Have you solved this yet? I'm getting the same issue with the session tokens storage callback being called multiple times, and then trying to load the first one that no longer exists. So my app fails to install. I would like to only have 1 record for each shop.
Its an error on shopify's end, I think theyre looking into it. For now use incognito to do your developments, that should work
So I think I might have fixed my issue. The problem was I wasn't returning a promise from the storage callback function (you also need this for the others). Before, the error would only happen occasionally, but 1 in 3 times at least. Now I've tested it several times and it hasn't had an error yet, so I think that was the issue.
Here's my working storage function:
export async function storeSession(session) {
console.debug(`Storing session: '${session.id}'...`);
let data = session; // Create a new object, so we can modify it
data.onlineAccessInfo = JSON.stringify(session.onlineAccessInfo); // Convert object to JSON
data.shop = data.shop.replace(/\/$/, ""); // Remove any trailing slash
let domain_id = "";
if (data.id.includes(data.shop)) {
domain_id = data.id;
}
// Execute SQL query
let query = new Promise((resolve, reject) => {
db.run(
`
INSERT INTO shops
(shop_url, session_id, domain_id, access_token, state, is_online, online_access_info, scope)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(shop_url) DO UPDATE SET
session_id=?, domain_id=?, access_token=?, state=?, is_online=?, online_access_info=?, scope=?
`,
[
data.shop, // The shop url
data.id,
domain_id,
data.accessToken,
data.state,
data.isOnline, // Is an online token
data.onlineAccessInfo, // Token info for online token
data.scope,
// On Conflict Update params
data.id,
domain_id,
data.accessToken,
data.state,
data.isOnline,
data.onlineAccessInfo,
data.scope,
],
// Process result
function (err) {
err ? reject(err) : resolve(); // if error, reject promise; otherwise resolve
}
);
});
// You need to return a Promise like this, using .then() .catch() syntax.
return await query
.then(() => {
// resolved
return true;
})
.catch((err) => {
// rejected
console.error(`Failed to store session '${data.id}'! SQL query failed: ` + err.message);
return false;
});
}
Thanks. That`s help a lot!
Okay, now I ran into another issue caused because of this. It still loads a new session token multiple times, and then the later one overwrites the first one. I'm trying to use apollo to make graphql requests, but it's using a previous session token that gets overwritten in the database, so it fails to make the request because the session is invalid.