Shopify / shopify-app-js

MIT License
284 stars 112 forks source link

`afterAuth` Hook not firing on access scope changes #1204

Closed ghussain08 closed 1 month ago

ghussain08 commented 3 months ago

I've noticed that the afterAuth hook is not invoked when access scopes are updated in the shopify.app.toml and .env file and the app is redeployed. Merchants see the approval screen and can update the access scopes, but the afterAuth hook, which is supposed to handle authentication, is only triggered during the initial app installation.

The name afterAuth suggests it should be invoked after any authentication event, but it is only invoked on app installation. Wouldn't a name like afterInstall be more appropriate to avoid confusion?

Is there a way to detect access scope changes when a merchant updates the app and approves new access scopes?

This is my setup code

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
  apiVersion: ApiVersion.April24,
  scopes: process.env.SCOPES?.split(","),
  appUrl: process.env.SHOPIFY_APP_URL || "",
  authPathPrefix: "/auth",
  sessionStorage: new CustomSessionStorage(),
  distribution: AppDistribution.AppStore,
  restResources,
  future: {
    unstable_newEmbeddedAuthStrategy: true,
  },
  ...(process.env.SHOP_CUSTOM_DOMAIN
    ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
    : {}),
  hooks: {
    afterAuth(ctx) {
      console.log("afterAuth", ctx);
    },
  },
  isEmbeddedApp: true,
});

I'm using CustomSessionStorage

import type { SessionStorage } from "@shopify/shopify-app-session-storage";
import prisma from "./db.server";
import { Session } from "@shopify/shopify-api";

export default class CustomSessionStorage implements SessionStorage {
  async deleteSession(id: string): Promise<boolean> {
    await prisma.session.update({
      where: { id: id },
      data: { isDeleted: true },
    });
    return true;
  }
  async deleteSessions(ids: string[]): Promise<boolean> {
    await prisma.session.updateMany({
      where: { id: { in: ids } },
      data: { isDeleted: true },
    });
    return true;
  }
  async findSessionsByShop(shop: string): Promise<Session[]> {
    const sessions = await prisma.session.findMany({
      where: { shop, isDeleted: false },
    });
    return sessions.map(
      (session) =>
        new Session({
          id: session.id,
          accessToken: session.accessToken,
          state: session.state,
          shop: session.shop,
          isOnline: true,
        }),
    );
  }
  async loadSession(id: string): Promise<Session | undefined> {
    const session = await prisma.session.findUnique({
      where: { id, isDeleted: false },
    });
    if (!session) return undefined;
    console.log(`Session loaded: ${session.id}`);
    return new Session({
      id: session.id,
      accessToken: session.accessToken,
      state: session.state,
      shop: session.shop,
      isOnline: true,
      scope: session.scope ? session.scope : undefined,
    });
  }
  async storeSession(session: Session): Promise<boolean> {
    console.log(`Storing session: ${session.id}`, session);
    await prisma.session.upsert({
      where: { id: session.id },
      update: {
        accessToken: session.accessToken,
        scope: session.scope,
        isDeleted: false,
        state: session.state,
      },
      create: {
        id: session.id,
        accessToken: session.accessToken!,
        scope: session.scope,
        isDeleted: false,
        state: session.state,
        shop: session.shop,
        installedAt: new Date(),
      },
    });
    console.log(`Session stored: ${session.id}`);
    return true;
  }
}
ghussain08 commented 3 months ago

Update

I'm not sure if this is the correct approach, but here’s my current solution:

I’ve added a new column named isScopeUpdated to the Session table. Whenever I deploy the app with updated scopes, I set isScopeUpdated to false for all stores. When fetching the session in loadSession, I only fetch sessions where isScopeUpdated is true. This forces Shopify to re-trigger token generation and fire the afterAuth hook.

I'm not sure if this is the correct approach, but here’s my current solution:

I’ve added a new column named isScopeUpdated to the Session table. Whenever I deploy the app with updated scopes, I set isScopeUpdated to false for all stores. When fetching the session in loadSession, I only fetch sessions where isScopeUpdated is true. This forces Shopify to re-trigger token generation and fire the afterAuth hook.

Updated CustomSessionStorage

import type { SessionStorage } from "@shopify/shopify-app-session-storage";
import prisma from "./db.server";
import { Session } from "@shopify/shopify-api";

export default class CustomSessionStorage implements SessionStorage {
  async deleteSession(id: string): Promise<boolean> {
    await prisma.session.update({
      where: { id: id },
      data: { isDeleted: true },
    });
    return true;
  }
  async deleteSessions(ids: string[]): Promise<boolean> {
    await prisma.session.updateMany({
      where: { id: { in: ids } },
      data: { isDeleted: true },
    });
    return true;
  }
  async findSessionsByShop(shop: string): Promise<Session[]> {
    const sessions = await prisma.session.findMany({
      where: { shop, isDeleted: false, isScopeUpdated: true },
    });
    return sessions.map(
      (session) =>
        new Session({
          id: session.id,
          accessToken: session.accessToken,
          state: session.state,
          shop: session.shop,
          isOnline: true,
        }),
    );
  }
  async loadSession(id: string): Promise<Session | undefined> {
    const session = await prisma.session.findUnique({
      where: { id, isDeleted: false, isScopeUpdated: true },
    });
    if (!session) return undefined;
    console.log(`Session loaded: ${session.id}`);
    return new Session({
      id: session.id,
      accessToken: session.accessToken,
      state: session.state,
      shop: session.shop,
      isOnline: true,
      scope: session.scope ? session.scope : undefined,
    });
  }
  async storeSession(session: Session): Promise<boolean> {
    console.log(`Storing session: ${session.id}`, session);
    await prisma.session.upsert({
      where: { id: session.id },
      update: {
        accessToken: session.accessToken,
        scope: session.scope,
        isDeleted: false,
        isScopeUpdated: true,
        state: session.state,
      },
      create: {
        id: session.id,
        accessToken: session.accessToken!,
        scope: session.scope,
        isDeleted: false,
        state: session.state,
        shop: session.shop,
        installedAt: new Date(),
        isScopeUpdated: true,
      },
    });
    console.log(`Session stored: ${session.id}`);
    return true;
  }
}
zzooeeyy commented 1 month ago

Hey @ghussain08 --

I'm sorry to hear you had to find a work-around to re-run afterAuth. afterAuth runs after token exchange occurs. Once the app is installed, token exchange usually will not be performed unless the access token has been invalidated by Shopify, the app, or is expired. In this case you're manually "invalidating" the accessToken by returning an undefined session.

If you want to know when the installed access scopes has been changed, we added a new Webhook topic app/scopes_update that notifies when the granted access scopes has been modified for an installation. It'll be available in the 10/2024 API release. You can subscribe to that topic and upon receiving that notification:

  1. Run your afterAuth operations (it should eliminate your work-around)
  2. Update the scopes column in the DB to reflect the newly granted scopes