Shopify / koa-shopify-auth

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

authentication with both offline and online tokens #106

Closed luciilucii closed 3 years ago

luciilucii commented 3 years ago

Issue summary

I'm trying to use both offline and online authentication with koa-shopify-auth. However, I cannot get it to work properly. I get different bugs at different times, not really sure how to fix them.

Expected behavior

I looked at issue #64 where this was also described in detail. When a merchant installs the app, I have this flow:

  1. createShopifyAuth with offline token (storing token, setting up webhooks etc., then routing to step 2 "/auth?=shop=...")
  2. createShopifyAuth with online token (routing to the app "/?shop=...")

Actual behavior

I get redirected to this url: https://....ngrok.io/install/auth/callback?code=... where I get the error "not found". The offline shopify afterAuth function gets called, but not the accessMode online afterAuth function.

Yesterday it worked in some way. I still saw a third-party cookie allowance screen, but it instantly redirected into the shopify admin of the app after that, and I got both tokens. Not a perfect way, but it was functional. I can't remember what I've changed since then.

Steps to reproduce the problem

I added both auth callback urls in the partner dashboard: https://...ngrok.io/auth/callback https://...ngrok.io/install/auth/callback

Here's the important server.js code:

server.use(
    createShopifyAuth({
        accessMode: "offline",
        prefix: "/install",
        async afterAuth(ctx) {
            const { 
                shop,
                accessToken,
                scope,
            } = ctx.state.shopify;

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

server.use(
    createShopifyAuth({
        async afterAuth(ctx) {
            const {
                shop,
                scope,
                accessToken,
            } = ctx.state.shopify

            console.log("online auth route", accessToken)

            try {
                await Shopify.Webhooks.Registry.register({
                    shop,
                    accessToken,
                    path: "/webhooks/app/uninstall",
                    topic: 'APP_UNINSTALLED',
                    apiVersion: ApiVersion.April21,
                    webhookHandler: appUninstallHelper.uninstallHandler
                })

                const alreadyInstalled = await appInstallHelper.handleInstall(shop, scope)

                if(alreadyInstalled) {
                    ctx.redirect(`/?shop=${shop}`)
                } else {
                    //Some first install functions
                    ctx.redirect(`/?shop=${shop}`)
                }
            } catch(err) {
                ctx.redirect(`/?shop=${shop}`)
            }
        },
    }),
)

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

    const isInstalled = await installChecker.isInstalled(shop)

    //TODO: Check if token is expired, if yes, redirect to /auth (without /install)
    if(!isInstalled) {
        ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
        await handleRequest(ctx);
    }
})

I've also tested changing the redirect url in the router.get("/") part to /auth instead of /install/auth. Then the app worked, but I obviously didn't call the accessMode offline afterAuth function, so I only got an online access token.

Additional Info

I'm using the newest version of koa-shopify-auth (4.1.3). The app completely worked with the online accessMode. The session is stored and loaded properly.

Second Scenario When only authenticating with the online accessMode, I tried getting the offline session directly by using Shopify.Utils.loadOfflineSession(shop) (I'm not sure if that is possible). I got a session with an access token that was different from the online access token, but when I used it in api calls, it returned an error (access token invalid).

luciilucii commented 3 years ago

Update: Changed nothing in the code, just switched the ngrok link. Now it's working again, but there's still a page for enabling cookies. It only shows the page for a short amount of time. Is there a way to fix this?

jezsung commented 3 years ago
Screen Shot 2021-06-07 at 6 29 39 AM

@luciilucii What's your App URL in the Shopify partners setup?

luciilucii commented 3 years ago

Screenshot 2021-06-07 at 08 24 38

Seems to work now, I guess there was a typo in my urls.

@jezsung any ideas on the third party cookie screen? Only happens with the offline token authentication.

Screenshot 2021-06-07 at 08 57 06

jezsung commented 3 years ago

@luciilucii I'm not sure, I've never seen that kind of screen. What browser do you use? Check if you disabled cookies settings on the browser.

luciilucii commented 3 years ago

Chrome with default settings. Previously had this issue especially in safari, with the old authentication methods.

It only shows for a second or two, the user doesn't have to click on "Enable cookies", but this is still very confusing. I've read in other issues, that this has to do with the initial auth cookie in the offline authentication. What seems weird is that this screen shows before redirecting to the shopify admin, so when the app is still "full screen" and not in an iframe.

jezsung commented 3 years ago

@luciilucii Does it only appear in Safari? If so, I think the issue might lie in Shopify. Maybe it could be something related to the SameSite attribute of Set-Cookie header. AFAIK this behavior isn't standardized yet.

luciilucii commented 3 years ago

Since implementing the offline access mode the screen also appears in chrome.

Found something else in my code. Seems like I didn't have a typo in the ngrok url. My accessMode offline afterAuth function was async.

I've used a Promise call inside the function like this:

db.set().then(() => {
    ctx.redirect('...')
})

When I use it like this, I get the error "Not found" issue from before. I've now changed the database call to:

try {
    await db.set()
    ctx.redirect('...')
} catch(err) {
    ...
}

That seems to fix the "Not found" issue. I will create a new issue for the cookie screen. Thanks for helping @jezsung

kwit75 commented 3 years ago

hey @luciilucii can you please share your code for bot offline and online token auth

luciilucii commented 3 years ago

@kwit75 it's the code from the issue summary. Make sure to include await calls before async functions in bots afterAuth methods. Or you can remove the before the afterAuth start and use promises instead. Hope that helps.

grallc commented 3 years ago

Hey, Thanks for the example. Are you still using the verifyRequest ? If you do, what parameters are you providing ? I've tried with router.get('(.*)', verifyRequest({ accessMode: 'online', authRoute: '/auth', fallbackRoute: '/install/auth' }), handleRequest) but it doesn't work, it stuck me in a redirect loop between '/', '/auth' and '/install/auth'.

Thanks!

luciilucii commented 3 years ago

Hey, yes, I still have the same verifyRequest function: router.get("(.*)", verifyRequest({ accessMode: 'online', authRoute: '/auth', fallbackRoute: '/install/auth', }), handleRequest)

Maybe check your router.get('/') function:

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

    const isInstalled = await installChecker.isInstalled(shop)
    const session = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res)

    if(!isInstalled) {
        ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
        if(session && session.expires && session.expires <= new Date()) {
            ctx.redirect(`/auth?shop=${shop}`);
        } else {
            await handleRequest(ctx);
        }
    }
})
grallc commented 3 years ago

Hey, yes, I still have the same verifyRequest function: router.get("(.*)", verifyRequest({ accessMode: 'online', authRoute: '/auth', fallbackRoute: '/install/auth', }), handleRequest)

Maybe check your router.get('/') function:

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

    const isInstalled = await installChecker.isInstalled(shop)
    const session = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res)

    if(!isInstalled) {
        ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
        if(session && session.expires && session.expires <= new Date()) {
            ctx.redirect(`/auth?shop=${shop}`);
        } else {
            await handleRequest(ctx);
        }
    }
})

Thanks! The "/" route fixed my issue :)

RockiRider commented 3 years ago

Hey @luciilucii couple questions about your code

1st: session with await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res) constantly returns as undefined for me (after the first session expires), are you storing the session somewhere?

2nd: With router.get("(.*)", verifyRequest({ accessMode: 'online', authRoute: '/auth', fallbackRoute: '/install/auth', }), handleRequest) Does this apply to every route apart from "/"?

grallc commented 3 years ago

Hey @luciilucii couple questions about your code

1st: session with await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res) constantly returns as undefined for me (after the first session expires), are you storing the session somewhere?

2nd: With router.get("(.*)", verifyRequest({ accessMode: 'online', authRoute: '/auth', fallbackRoute: '/install/auth', }), handleRequest) Does this apply to every route apart from "/"?

Hi, I had the same issue with the loadCurrentSession. The only solution I've found is to use the Bearer token:

import { getSessionToken } from '@shopify/app-bridge-utils'
const app = useAppBridge()
const sessionToken = await getSessionToken(app)

The useAppBridge has to be in a React component, since it's a hook. I personally fetch the sessionToken in an useEffect when the app loads then dispatch it in the redux store.

Now that you have it, you can use it with any fetch function. (I use Axios)

const request = await axios.get('/api/myroute', { headers: { Authorization: 'Bearer ${sessionToken}' } })` (replace the single quote with the backquote to build the string)

Also make sure to respect the header's formatting ('bearer' etc) otherwise it will not work.

Now that you have your front-end request, you can easily handle this request in the back end: In each route that requires authentication, add something like this:

const storeUrl = await getSession(ctx)
if (storeUrl === null) {
    ctx.body = { error: 'Invalid session' }
    ctx.res.statusCode = 403
    return
}

And the getSession function:

const getSession = async (ctx: Koa.ParameterizedContext<any, Router.IRouterParamContext<any>, any>): Promise<string | null> => {
    const token = ((ctx.get('Authorization') || '') as string).replace('Bearer ', '')
    let session: string | null = null
    try {
        const sessionData = await jwt.verify(token, process.env.SHOPIFY_API_SECRET as string) as { dest: string }
        session = sessionData.dest.replace('https://', '')
    } catch {
        session = null
    }
    return session
}

Let me know if you figure this out! But here's all the code you need :)

luciilucii commented 3 years ago

1st: session with await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res) constantly returns as undefined for me (after the first session expires), are you storing the session somewhere?

Yep, I've implemented the logic to store the session in our database. There should be plenty of resources available for this, you can check the documentation of this package and the @shopify/shopify-api package (Although it takes a while to really understand the concept because there are 3 different session ids).

I would also recommend searching in the github issues since there can be confusion with how to handle dates.

MrLightful commented 3 years ago

Here's the important server.js code

Oh, damn, it is exactly the thing I was looking for. Thanks a lot, @luciilucii!

govindrai commented 2 years ago

@luciilucii Holy smokes man, your issue example, exactly what I was looking for. I would think that is a super common scenario where an app needs both online and offline tokens. It would've been great if you could get both from Shopify by specifying an array of ["online", "offline"] since this just adds unnecessary complexity imo.

Thanks so much for sharing your code. I definitely wasted a few days on this.

@paulomarg Do you think it would be a good idea for the library to support returning multiple sessions (online, offline)?