diff --git a/packages/api-client-core/package.json b/packages/api-client-core/package.json index c7ba11df7..74dc2c4c0 100644 --- a/packages/api-client-core/package.json +++ b/packages/api-client-core/package.json @@ -48,6 +48,7 @@ "globby": "^11.0.4", "gql-tag": "^1.0.1", "nock": "^13.3.1", + "p-retry": "^4.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "tiny-graphql-query-compiler": "workspace:*", diff --git a/packages/api-client-core/spec/mockUrqlClient.ts b/packages/api-client-core/spec/mockUrqlClient.ts index 852635597..7923822cc 100644 --- a/packages/api-client-core/spec/mockUrqlClient.ts +++ b/packages/api-client-core/spec/mockUrqlClient.ts @@ -2,7 +2,8 @@ import type { Client, GraphQLRequest, OperationContext, OperationResult, Operati import { createRequest, makeErrorResult } from "@urql/core"; import type { DocumentNode, ExecutionResult, OperationDefinitionNode } from "graphql"; import type { SubscribePayload, Client as SubscriptionClient, Sink as SubscriptionSink } from "graphql-ws"; -import { find, findLast } from "lodash"; +import { defaults, find, findLast } from "lodash"; +import pRetry from "p-retry"; import { act } from "react-dom/test-utils"; import type { Sink, Source, Subject } from "wonka"; import { filter, makeSubject, pipe, subscribe, take, toPromise } from "wonka"; @@ -36,6 +37,11 @@ export type MockOperationFn = jest.Mock & { * One should ensure the appropriate `executeXYZ` call has been made by urql, then call this function. */ pushResponse: (key: string, response: Omit) => void; + /** + * + * Waits for a subject to be created for a given key. This is useful for ensuring waiting in a test for a query or mutation to be run + */ + waitForSubject: (key: string) => Promise; }; export type MockFetchFn = jest.Mock & { @@ -115,6 +121,22 @@ const newMockOperationFn = (assertions?: (request: GraphQLRequest) => void) => { }); }; + fn.waitForSubject = async (key: string, options?: pRetry.Options) => { + await pRetry( + () => { + if (subjects[key]) { + return; + } + throw new Error(`No mock client subject started for key ${key}, options are ${Object.keys(subjects).join(", ")}`); + }, + defaults(options, { + attempts: 20, + minTimeout: 10, + maxTimeout: 250, + }) + ); + }; + return fn; }; diff --git a/packages/react-shopify-app-bridge/package.json b/packages/react-shopify-app-bridge/package.json index 1fce4b629..583141040 100644 --- a/packages/react-shopify-app-bridge/package.json +++ b/packages/react-shopify-app-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/react-shopify-app-bridge", - "version": "0.14.2", + "version": "0.15.0", "files": [ "README.md", "dist/**/*" diff --git a/packages/react-shopify-app-bridge/spec/Provider.spec.tsx b/packages/react-shopify-app-bridge/spec/Provider.spec.tsx index d26d04ca2..52fb803d4 100644 --- a/packages/react-shopify-app-bridge/spec/Provider.spec.tsx +++ b/packages/react-shopify-app-bridge/spec/Provider.spec.tsx @@ -2,7 +2,7 @@ import type { AnyClient } from "@gadgetinc/api-client-core"; import { GadgetConnection } from "@gadgetinc/api-client-core"; import * as AppBridgeReact from "@shopify/app-bridge-react"; import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import React from "react"; import { mockUrqlClient } from "../../api-client-core/spec/mockUrqlClient.js"; import { AppType, Provider } from "../src/Provider.js"; @@ -36,6 +36,12 @@ describe("GadgetProvider", () => { embedded: false, mobile: false, }, + config: { + apiKey: mockApiKey, + shop: "example.myshopify.com", + locale: "en", + }, + idToken: () => Promise.resolve("mock-id-token"), }; useAppBridgeMock = jest.spyOn(AppBridgeReact, "useAppBridge").mockImplementation(() => window.shopify); @@ -79,23 +85,41 @@ describe("GadgetProvider", () => { } }); - test("can render an embedded app type", () => { + test("can render an embedded app type", async () => { const { container } = render( hello world ); - mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { - data: { - currentSession: { - shop: null, + act(() => { + mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { + data: { + currentSession: { + shop: null, + }, }, - }, - stale: false, - hasNext: false, + stale: false, + hasNext: false, + }); }); + if (isInstallRequest) { + await act(async () => { + await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange"); + + mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", { + data: { + shopifyInstallByTokenExchange: { + success: false, + }, + }, + stale: false, + hasNext: false, + }); + }); + } + if (isInstallRequest) { expect(mockOpen).toHaveBeenCalledWith( "https://test-app.gadget.app/api/connections/auth/shopify?shop=example.myshopify.com&hmac=abcdefg&host=abcdfg", @@ -151,7 +175,7 @@ describe("GadgetProvider", () => { window.shopify.environment.mobile = false; }); - test("can render an embedded app type in embedded context", () => { + test("can render an embedded app type in embedded context", async () => { // @ts-expect-error mock jest.spyOn(window, "self", "get").mockImplementation(() => ({})); @@ -161,16 +185,34 @@ describe("GadgetProvider", () => { ); - mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { - data: { - currentSession: { - shop: null, + act(() => { + mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { + data: { + currentSession: { + shop: null, + }, }, - }, - stale: false, - hasNext: false, + stale: false, + hasNext: false, + }); }); + if (isInstallRequest) { + await act(async () => { + await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange"); + + mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", { + data: { + shopifyInstallByTokenExchange: { + success: false, + }, + }, + stale: false, + hasNext: false, + }); + }); + } + if (isInstallRequest) { expect(mockOpen).toHaveBeenCalledWith( "https://test-app.gadget.app/api/connections/auth/shopify?shop=example.myshopify.com&hmac=abcdefg&host=abcdfg", @@ -211,6 +253,155 @@ describe("GadgetProvider", () => { window.shopify.environment.embedded = false; }); + if (isInstallRequest) { + test("can render an embedded app type that's already authenticated but needs reauthentication via token exchange", async () => { + window.shopify.environment.embedded = true; + + render( + + hello world + + ); + + act(() => { + mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { + data: { + currentSession: { + shop: { + id: "123", + }, + }, + shopifyConnection: { + requiresReauthentication: true, + }, + }, + stale: false, + hasNext: false, + }); + }); + + await act(async () => { + await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange"); + + mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", { + data: { + shopifyInstallByTokenExchange: { + success: true, + }, + }, + stale: false, + hasNext: false, + }); + }); + + expect(mockOpen).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + window.shopify.environment.embedded = false; + }); + + test("can render an embedded app type that's already authenticated but needs reauthentication via redirect", async () => { + window.shopify.environment.embedded = true; + + render( + + hello world + + ); + + act(() => { + mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { + data: { + currentSession: { + shop: { + id: "123", + }, + }, + shopifyConnection: { + requiresReauthentication: true, + }, + }, + stale: false, + hasNext: false, + }); + }); + + await act(async () => { + await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange"); + + mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", { + data: { + shopifyInstallByTokenExchange: { + success: false, + }, + }, + stale: false, + hasNext: false, + }); + }); + + expect(mockOpen).toHaveBeenCalledWith( + "https://test-app.gadget.app/api/connections/auth/shopify?shop=example.myshopify.com&hmac=abcdefg&host=abcdfg", + "_top" + ); + expect(mockNavigate).not.toHaveBeenCalled(); + + window.shopify.environment.embedded = false; + }); + + test("doesn't redirect an shopify managed installation for an embedded app type that's already authenticated but needs reauthentication", async () => { + window.shopify.environment.embedded = true; + + render( + + hello world + + ); + + act(() => { + mockUrqlClient.executeQuery.pushResponse("GetSessionForShopifyApp", { + data: { + currentSession: { + shop: { + id: "123", + }, + }, + shopifyConnection: { + requiresReauthentication: true, + connectedApps: [ + { + apiKey: mockApiKey, + shopifyManagedInstallation: true, + }, + ], + }, + }, + stale: false, + hasNext: false, + }); + }); + + await act(async () => { + await mockUrqlClient.executeMutation.waitForSubject("InstallByTokenExchange"); + + mockUrqlClient.executeMutation.pushResponse("InstallByTokenExchange", { + data: { + shopifyInstallByTokenExchange: { + success: false, + }, + }, + stale: false, + hasNext: false, + }); + }); + + expect(mockOpen).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + window.shopify.environment.embedded = false; + }); + } + test("should throw if embedded app type is in embedded context and shopify global is not defined", () => { // @ts-expect-error mock const windowSelf = jest.spyOn(window, "self", "get").mockImplementation(() => ({})); diff --git a/packages/react-shopify-app-bridge/spec/useGadget.spec.tsx b/packages/react-shopify-app-bridge/spec/useGadget.spec.tsx index 7fa6cfa00..a8294eab2 100644 --- a/packages/react-shopify-app-bridge/spec/useGadget.spec.tsx +++ b/packages/react-shopify-app-bridge/spec/useGadget.spec.tsx @@ -44,6 +44,12 @@ describe("useGadget", () => { embedded: false, mobile: false, }, + config: { + apiKey: mockApiKey, + shop: "example.myshopify.com", + locale: "en", + }, + idToken: () => Promise.resolve("mock-id-token"), }; useAppBridgeMock = jest.spyOn(AppBridgeReact, "useAppBridge").mockImplementation(() => window.shopify); diff --git a/packages/react-shopify-app-bridge/src/Provider.tsx b/packages/react-shopify-app-bridge/src/Provider.tsx index 3734f02ce..ef4389773 100644 --- a/packages/react-shopify-app-bridge/src/Provider.tsx +++ b/packages/react-shopify-app-bridge/src/Provider.tsx @@ -1,5 +1,5 @@ import type { AnyClient } from "@gadgetinc/api-client-core"; -import { Provider as GadgetUrqlProvider, useQuery } from "@gadgetinc/react"; +import { Provider as GadgetUrqlProvider, useMutation, useQuery } from "@gadgetinc/react"; import { useAppBridge } from "@shopify/app-bridge-react"; import type { ReactNode } from "react"; import React, { memo, useEffect, useMemo, useState } from "react"; @@ -33,6 +33,18 @@ const GetCurrentSessionQuery = ` } shopifyConnection { requiresReauthentication + connectedApps { + apiKey + shopifyManagedInstallation + } + } + } +`; + +const InstallByTokenExchangeMutation = ` + mutation InstallByTokenExchange($shopifySessionToken: String!) { + shopifyInstallByTokenExchange(shopifySessionToken: $shopifySessionToken) { + success } } `; @@ -59,11 +71,14 @@ const InnerGadgetProvider = memo( isRootFrameRequest: false, }); + const apiKey = appBridge?.config.apiKey; + useEffect(() => { if (!appBridge) { console.debug("[gadget-rsab] no app bridge, skipping client auth setup"); return; } + // setup the api client to always query using the custom shopify auth implementation api.connection.setAuthenticationMode({ custom: { @@ -84,30 +99,65 @@ const InnerGadgetProvider = memo( console.debug("[gadget-rsab] set up client auth for session tokens"); }, [api.connection, appBridge]); - let runningShopifyAuth = false; + let requiresReauthentication = false; let isAuthenticated = false; + let isShopifyManagedInstallation = false; // always run one session fetch to the gadget backend on boot to discover if this app is installed const [{ data: currentSessionData, fetching: sessionFetching, error }] = useQuery({ query: GetCurrentSessionQuery, }); + const [{ data: attemptedInstallResult, fetching: attemptingInstallByTokenExchange }, attemptInstallByTokenExchange] = + useMutation(InstallByTokenExchangeMutation); + + const tokenExchangeAttempted = !!attemptedInstallResult; + if (currentSessionData) { - runningShopifyAuth = currentSessionData.shopifyConnection?.requiresReauthentication; + if (currentSessionData.shopifyConnection) { + requiresReauthentication = currentSessionData.shopifyConnection.requiresReauthentication; + isShopifyManagedInstallation = (currentSessionData.shopifyConnection.connectedApps ?? []).some( + (app: { apiKey: string; shopifyManagedInstallation: boolean }) => app.apiKey === apiKey && app.shopifyManagedInstallation + ); + } if (currentSessionData.currentSession) { if (!currentSessionData.currentSession.shop) { - runningShopifyAuth = true; + requiresReauthentication = true; } else { // we may be missing scopes, if so, we aren't fully authenticated - isAuthenticated = !currentSessionData.shopifyConnection?.requiresReauthentication; + isAuthenticated = !requiresReauthentication; } } } + if (attemptedInstallResult && attemptedInstallResult.shopifyInstallByTokenExchange?.success) { + console.debug("[gadget-rsab] successfully installed by token exchange"); + + isAuthenticated = true; + requiresReauthentication = false; + } + + // if we think reauthentication is required, we should first attempt to install by token exchange + useEffect(() => { + if (!requiresReauthentication || isRootFrameRequest) return; + + appBridge + .idToken() + .then((shopifySessionToken) => { + console.debug("[gadget-rsab] attempting install by token exchange"); + attemptInstallByTokenExchange({ shopifySessionToken }).catch((err) => { + console.debug({ err }, "[gadget-rsab] failed to install by token exchange"); + }); + }) + .catch((err) => { + console.debug({ err }, "[gadget-rsab] failed to get shopify session token"); + }); + }, [appBridge, requiresReauthentication, isRootFrameRequest, attemptInstallByTokenExchange]); + // redirect to Gadget to initiate the oauth process if we need to. useEffect(() => { - if (!runningShopifyAuth || isRootFrameRequest) return; + if (!requiresReauthentication || !tokenExchangeAttempted || isRootFrameRequest || isShopifyManagedInstallation) return; // redirect to gadget app root pages url with oauth params const redirectURL = new URL("/api/connections/auth/shopify", gadgetAppUrl); redirectURL.search = originalQueryParams?.toString() ?? ""; @@ -123,9 +173,17 @@ const InnerGadgetProvider = memo( open(redirectURLWithOAuthParams, "_top"); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gadgetAppUrl, isRootFrameRequest, originalQueryParams, runningShopifyAuth]); - - const loading = (forceRedirect || runningShopifyAuth || sessionFetching) && !isRootFrameRequest; + }, [ + gadgetAppUrl, + isRootFrameRequest, + originalQueryParams, + requiresReauthentication, + tokenExchangeAttempted, + isShopifyManagedInstallation, + ]); + + const loading = + (forceRedirect || requiresReauthentication || sessionFetching || attemptingInstallByTokenExchange) && !isRootFrameRequest; useEffect(() => { const context = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9e5d2efe..0f2416b9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: nock: specifier: ^13.3.1 version: 13.3.1 + p-retry: + specifier: ^4.5.0 + version: 4.6.2 react: specifier: ^18.2.0 version: 18.2.0 @@ -2461,6 +2464,10 @@ packages: csstype: 3.1.0 dev: true + /@types/retry@0.12.0: + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + dev: true + /@types/scheduler@0.16.2: resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==} dev: true @@ -6319,6 +6326,14 @@ packages: aggregate-error: 3.1.0 dev: true + /p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + dev: true + /p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -6775,6 +6790,11 @@ packages: signal-exit: 3.0.7 dev: true + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: true + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}