diff --git a/.changeset/pink-horses-unite.md b/.changeset/pink-horses-unite.md new file mode 100644 index 0000000000..2bb64e4289 --- /dev/null +++ b/.changeset/pink-horses-unite.md @@ -0,0 +1,5 @@ +--- +'@shopify/shopify-app-remix': minor +--- + +Add new embedded authorization strategy relying on Shopify managed install and OAuth token exchange diff --git a/packages/shopify-app-remix/src/server/__test-helpers/const.ts b/packages/shopify-app-remix/src/server/__test-helpers/const.ts index d41729a69c..63442bc23f 100644 --- a/packages/shopify-app-remix/src/server/__test-helpers/const.ts +++ b/packages/shopify-app-remix/src/server/__test-helpers/const.ts @@ -5,6 +5,7 @@ export const API_KEY = 'testApiKey'; export const APP_URL = 'https://my-test-app.myshopify.io'; export const SHOPIFY_HOST = 'totally-real-host.myshopify.io'; export const BASE64_HOST = Buffer.from(SHOPIFY_HOST).toString('base64'); -export const TEST_SHOP = 'test-shop.myshopify.com'; +export const TEST_SHOP_NAME = 'test-shop'; +export const TEST_SHOP = `${TEST_SHOP_NAME}.myshopify.com`; export const GRAPHQL_URL = `https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/graphql.json`; export const USER_ID = 12345; diff --git a/packages/shopify-app-remix/src/server/__test-helpers/setup-valid-session.ts b/packages/shopify-app-remix/src/server/__test-helpers/setup-valid-session.ts index f79601bbbb..ac4cfa4035 100644 --- a/packages/shopify-app-remix/src/server/__test-helpers/setup-valid-session.ts +++ b/packages/shopify-app-remix/src/server/__test-helpers/setup-valid-session.ts @@ -5,14 +5,15 @@ import {TEST_SHOP, USER_ID} from './const'; export async function setUpValidSession( sessionStorage: SessionStorage, - isOnline = false, + sessionParams?: Partial, ): Promise { const overrides: Partial = {}; let id = `offline_${TEST_SHOP}`; - if (isOnline) { + if (sessionParams?.isOnline) { id = `${TEST_SHOP}_${USER_ID}`; // Expires one day from now - overrides.expires = new Date(Date.now() + 1000 * 3600 * 24); + overrides.expires = + sessionParams.expires || new Date(Date.now() + 1000 * 3600 * 24); overrides.onlineAccessInfo = { associated_user_scope: 'testScope', expires_in: 3600 * 24, @@ -32,7 +33,7 @@ export async function setUpValidSession( const session = new Session({ id, shop: TEST_SHOP, - isOnline, + isOnline: Boolean(sessionParams?.isOnline), state: 'test', accessToken: 'totally_real_token', scope: 'testScope', diff --git a/packages/shopify-app-remix/src/server/__test-helpers/test-config.ts b/packages/shopify-app-remix/src/server/__test-helpers/test-config.ts index d323b072b7..9f9104f793 100644 --- a/packages/shopify-app-remix/src/server/__test-helpers/test-config.ts +++ b/packages/shopify-app-remix/src/server/__test-helpers/test-config.ts @@ -21,6 +21,7 @@ import {API_KEY, API_SECRET_KEY, APP_URL} from './const'; const TEST_FUTURE_FLAGS: Required<{[key in keyof FutureFlags]: true}> = { v3_authenticatePublic: true, v3_webhookAdminContext: true, + unstable_newEmbeddedAuthStrategy: true, } as const; const TEST_CONFIG = { diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/admin-client.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/admin-client.test.ts index 8d189ce97b..37307ef2a5 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/admin-client.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/admin-client.test.ts @@ -11,7 +11,6 @@ import { APP_URL, BASE64_HOST, TEST_SHOP, - expectExitIframeRedirect, getJwt, getThrownResponse, setUpValidSession, @@ -21,7 +20,6 @@ import { expectAdminApiClient, } from '../../../__test-helpers'; import {shopifyApp} from '../../..'; -import {REAUTH_URL_HEADER} from '../../const'; import {AdminApiContext} from '../../../clients'; describe('admin.authenticate context', () => { @@ -102,25 +100,6 @@ describe('admin.authenticate context', () => { ])( '$testGroup re-authentication', ({testGroup: _testGroup, mockRequest, action}) => { - it('redirects to auth when request receives a 401 response and not embedded', async () => { - // GIVEN - const {admin, session} = await setUpNonEmbeddedFlow(); - const requestMock = await mockRequest(401); - - // WHEN - const response = await getThrownResponse( - async () => action(admin, session), - requestMock, - ); - - // THEN - expect(response.status).toEqual(302); - - const {hostname, pathname} = new URL(response.headers.get('Location')!); - expect(hostname).toEqual(TEST_SHOP); - expect(pathname).toEqual('/admin/oauth/authorize'); - }); - it('throws a response when request receives a non-401 response and not embedded', async () => { // GIVEN const {admin, session} = await setUpNonEmbeddedFlow(); @@ -135,43 +114,6 @@ describe('admin.authenticate context', () => { // THEN expect(response.status).toEqual(403); }); - - it('redirects to exit iframe when request receives a 401 response and embedded', async () => { - // GIVEN - const {admin, session} = await setUpEmbeddedFlow(); - const requestMock = await mockRequest(401); - - // WHEN - const response = await getThrownResponse( - async () => action(admin, session), - requestMock, - ); - - // THEN - expectExitIframeRedirect(response); - }); - - it('returns app bridge redirection headers when request receives a 401 response on fetch requests', async () => { - // GIVEN - const {admin, session} = await setUpFetchFlow(); - const requestMock = await mockRequest(401); - - // WHEN - const response = await getThrownResponse( - async () => action(admin, session), - requestMock, - ); - - // THEN - expect(response.status).toEqual(401); - - const {origin, pathname, searchParams} = new URL( - response.headers.get(REAUTH_URL_HEADER)!, - ); - expect(origin).toEqual(APP_URL); - expect(pathname).toEqual('/auth'); - expect(searchParams.get('shop')).toEqual(TEST_SHOP); - }); }, ); }); @@ -192,21 +134,6 @@ async function setUpEmbeddedFlow() { }; } -async function setUpFetchFlow() { - const shopify = shopifyApp(testConfig({restResources})); - await setUpValidSession(shopify.sessionStorage); - - const {token} = getJwt(); - const request = new Request(APP_URL, { - headers: {Authorization: `Bearer ${token}`}, - }); - - return { - shopify, - ...(await shopify.authenticate.admin(request)), - }; -} - async function setUpNonEmbeddedFlow() { const shopify = shopifyApp(testConfig({restResources, isEmbeddedApp: false})); const session = await setUpValidSession(shopify.sessionStorage); diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/doc-request-path.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/doc-request-path.test.ts index d5735c93b2..8f98ff3391 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/doc-request-path.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/doc-request-path.test.ts @@ -128,4 +128,24 @@ describe('authorize.admin doc request path', () => { // THEN expect(response.status).toBe(400); }); + + it("redirects to the embedded app URL if the app isn't embedded yet", async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp(config); + await setUpValidSession(shopify.sessionStorage); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`), + ); + + // THEN + const {hostname, pathname} = new URL(response.headers.get('location')!); + + expect(response.status).toBe(302); + expect(hostname).toBe(SHOPIFY_HOST); + expect(pathname).toBe(`/apps/${API_KEY}`); + }); }); diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/session-token-header-path.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/session-token-header-path.test.ts index 06dac552c6..4c582fce57 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/session-token-header-path.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/__tests__/session-token-header-path.test.ts @@ -30,59 +30,6 @@ describe('authorize.session token header path', () => { // THEN expect(response.status).toBe(401); }); - - describe.each([true, false])('when isOnline: %s', (isOnline) => { - it(`returns app bridge redirection headers if there is no session`, async () => { - // GIVEN - const shopify = shopifyApp(testConfig({useOnlineTokens: isOnline})); - - // WHEN - const {token} = getJwt(); - const response = await getThrownResponse( - shopify.authenticate.admin, - new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`, { - headers: {Authorization: `Bearer ${token}`}, - }), - ); - - // THEN - const {origin, pathname, searchParams} = new URL( - response.headers.get(REAUTH_URL_HEADER)!, - ); - - expect(response.status).toBe(401); - expect(origin).toBe(APP_URL); - expect(pathname).toBe('/auth'); - expect(searchParams.get('shop')).toBe(TEST_SHOP); - }); - - it(`returns app bridge redirection headers if the session is no longer valid`, async () => { - // GIVEN - const shopify = shopifyApp( - testConfig({useOnlineTokens: isOnline, scopes: ['otherTestScope']}), - ); - // The session scopes don't match the configured scopes, so it needs to be reset - await setUpValidSession(shopify.sessionStorage, isOnline); - - // WHEN - const {token} = getJwt(); - const response = await getThrownResponse( - shopify.authenticate.admin, - new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`, { - headers: {Authorization: `Bearer ${token}`}, - }), - ); - - // THEN - const {origin, pathname, searchParams} = new URL( - response.headers.get(REAUTH_URL_HEADER)!, - ); - expect(response.status).toBe(401); - expect(origin).toBe(APP_URL); - expect(pathname).toBe('/auth'); - expect(searchParams.get('shop')).toBe(TEST_SHOP); - }); - }); }); describe.each([true, false])( @@ -92,10 +39,9 @@ describe('authorize.session token header path', () => { // GIVEN const shopify = shopifyApp(testConfig({useOnlineTokens: isOnline})); - const testSession = await setUpValidSession( - shopify.sessionStorage, + const testSession = await setUpValidSession(shopify.sessionStorage, { isOnline, - ); + }); // WHEN const {token, payload} = getJwt(); @@ -120,7 +66,9 @@ describe('authorize.session token header path', () => { let testSession: Session; testSession = await setUpValidSession(shopify.sessionStorage); if (isOnline) { - testSession = await setUpValidSession(shopify.sessionStorage, true); + testSession = await setUpValidSession(shopify.sessionStorage, { + isOnline: true, + }); } // WHEN diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/authenticate.ts b/packages/shopify-app-remix/src/server/authenticate/admin/authenticate.ts index 87689cd97f..8782e8ca1e 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/authenticate.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/authenticate.ts @@ -30,7 +30,14 @@ import { renderAppBridge, validateShopAndHostParams, } from './helpers'; -import {AuthorizationStrategy, SessionTokenContext} from './strategies/types'; +import {AuthorizationStrategy} from './strategies/types'; + +export interface SessionTokenContext { + shop: string; + sessionId?: string; + sessionToken?: string; + payload?: JwtPayload; +} interface AuthStrategyParams extends BasicParams { strategy: AuthorizationStrategy; @@ -68,16 +75,17 @@ export function authStrategyFactory< function createContext( request: Request, session: Session, + authStrategy: AuthorizationStrategy, sessionToken?: JwtPayload, ): AdminContext { const context: | EmbeddedAdminContext | NonEmbeddedAdminContext = { - admin: createAdminApiContext(request, session, { - api, - logger, - config, - }), + admin: createAdminApiContext( + session, + params, + authStrategy.handleClientError(request), + ), billing: { require: requireBillingFactory(params, request, session), request: requestBillingFactory(params, request, session), @@ -116,28 +124,26 @@ export function authStrategyFactory< logger.info('Authenticating admin request'); - const {payload, shop, sessionId} = await getSessionTokenContext( - params, - request, - ); + const {payload, shop, sessionId, sessionToken} = + await getSessionTokenContext(params, request); logger.debug('Loading session from storage', {sessionId}); const existingSession = sessionId ? await config.sessionStorage.loadSession(sessionId) : undefined; - const session = await strategy.authenticate( - request, - existingSession, + const session = await strategy.authenticate(request, { + session: existingSession, + sessionToken, shop, - ); + }); logger.debug('Request is valid, loaded session from session token', { shop: session.shop, isOnline: session.isOnline, }); - return createContext(request, session, payload); + return createContext(request, session, strategy, payload); } catch (errorOrResponse) { if (errorOrResponse instanceof Response) { ensureCORSHeadersFactory(params, request)(errorOrResponse); @@ -159,10 +165,10 @@ async function getSessionTokenContext( const sessionToken = (headerSessionToken || searchParamSessionToken)!; logger.debug('Attempting to authenticate session token', { - sessionToken: { + sessionToken: JSON.stringify({ header: headerSessionToken, search: searchParamSessionToken, - }, + }), }); if (config.isEmbeddedApp) { @@ -178,7 +184,7 @@ async function getSessionTokenContext( ? api.session.getJwtSessionId(shop, payload.sub) : api.session.getOfflineId(shop); - return {shop, payload, sessionId}; + return {shop, payload, sessionId, sessionToken}; } const url = new URL(request.url); @@ -189,5 +195,5 @@ async function getSessionTokenContext( rawRequest: request, }); - return {shop, sessionId, payload: undefined}; + return {shop, sessionId, payload: undefined, sessionToken}; } diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/create-admin-api-context.ts b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/create-admin-api-context.ts index 3699bc810d..00caf4bb87 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/create-admin-api-context.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/create-admin-api-context.ts @@ -1,22 +1,22 @@ import {Session, ShopifyRestResources} from '@shopify/shopify-api'; import type {BasicParams} from '../../../types'; -import {AdminApiContext, adminClientFactory} from '../../../clients/admin'; - -import {handleClientErrorFactory} from './handle-client-error'; +import { + AdminApiContext, + HandleAdminClientError, + adminClientFactory, +} from '../../../clients/admin'; export function createAdminApiContext< Resources extends ShopifyRestResources = ShopifyRestResources, >( - request: Request, session: Session, params: BasicParams, + handleClientError: HandleAdminClientError, ): AdminApiContext { return adminClientFactory({ session, params, - handleClientError: handleClientErrorFactory({ - request, - }), + handleClientError, }); } diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/handle-client-error.ts b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/handle-client-error.ts index 1661533fb4..fda47f8092 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/handle-client-error.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/handle-client-error.ts @@ -1,15 +1,11 @@ import {HttpResponseError} from '@shopify/shopify-api'; import type {HandleAdminClientError} from '../../../clients/admin/types'; - -import {redirectToAuthPage} from './redirect-to-auth-page'; - -interface HandleClientErrorOptions { - request: Request; -} +import {HandleClientErrorOptions} from '../strategies/types'; export function handleClientErrorFactory({ request, + onError, }: HandleClientErrorOptions): HandleAdminClientError { return async function handleClientError({ error, @@ -32,8 +28,8 @@ export function handleClientErrorFactory({ }, ); - if (error.response.code === 401) { - throw await redirectToAuthPage(params, request, session.shop); + if (onError) { + await onError({request, session, error}); } // forward a minimal copy of the upstream HTTP response instead of an Error: diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/redirect-to-bounce-page.ts b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/redirect-to-bounce-page.ts index 9797c34af2..0ba5c46ffc 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/redirect-to-bounce-page.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/redirect-to-bounce-page.ts @@ -7,13 +7,17 @@ export const redirectToBouncePage = (params: BasicParams, url: URL): never => { // Make sure we always point to the configured app URL so it also works behind reverse proxies (that alter the Host // header). - url.searchParams.set( + const searchParams = url.searchParams; + searchParams.delete('id_token'); + searchParams.set( 'shopify-reload', - `${config.appUrl}${url.pathname}${url.search}`, + `${config.appUrl}${url.pathname}?${searchParams.toString()}`, ); // eslint-disable-next-line no-warning-comments // TODO Make sure this works on chrome without a tunnel (weird HTTPS redirect issue) // https://github.com/orgs/Shopify/projects/6899/views/1?pane=issue&itemId=28376650 - throw redirect(`${config.auth.patchSessionTokenPath}${url.search}`); + throw redirect( + `${config.auth.patchSessionTokenPath}?${searchParams.toString()}`, + ); }; diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/trigger-after-auth-hook.ts b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/trigger-after-auth-hook.ts index 18eb238738..b27dd3f1a2 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/helpers/trigger-after-auth-hook.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/helpers/trigger-after-auth-hook.ts @@ -1,18 +1,28 @@ import {Session, ShopifyRestResources} from '@shopify/shopify-api'; import type {BasicParams} from '../../../types'; +import {AuthorizationStrategy} from '../strategies/types'; import {createAdminApiContext} from './create-admin-api-context'; export async function triggerAfterAuthHook< Resources extends ShopifyRestResources = ShopifyRestResources, ->(params: BasicParams, session: Session, request: Request) { +>( + params: BasicParams, + session: Session, + request: Request, + authStrategy: AuthorizationStrategy, +) { const {config, logger} = params; if (config.hooks.afterAuth) { logger.info('Running afterAuth hook'); await config.hooks.afterAuth({ session, - admin: createAdminApiContext(request, session, params), + admin: createAdminApiContext( + session, + params, + authStrategy.handleClientError(request), + ), }); } } diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/admin-client.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/admin-client.test.ts new file mode 100644 index 0000000000..729e37f67f --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/admin-client.test.ts @@ -0,0 +1,222 @@ +import { + ApiVersion, + LATEST_API_VERSION, + SESSION_COOKIE_NAME, + Session, +} from '@shopify/shopify-api'; +import {restResources} from '@shopify/shopify-api/rest/admin/2023-04'; + +import { + APP_URL, + BASE64_HOST, + TEST_SHOP, + expectExitIframeRedirect, + getJwt, + getThrownResponse, + setUpValidSession, + signRequestCookie, + testConfig, + mockExternalRequest, + expectAdminApiClient, +} from '../../../../../__test-helpers'; +import {shopifyApp} from '../../../../..'; +import {REAUTH_URL_HEADER} from '../../../../const'; +import {AdminApiContext} from '../../../../../clients'; + +describe('admin.authenticate context', () => { + expectAdminApiClient(async () => { + const { + admin, + expectedSession, + session: actualSession, + } = await setUpEmbeddedFlow(); + + return {admin, expectedSession, actualSession}; + }); + describe.each([ + { + testGroup: 'REST client', + mockRequest: mockRestRequest, + action: async (admin: AdminApiContext, _session: Session) => + admin.rest.get({path: '/customers.json'}), + }, + { + testGroup: 'REST resources', + mockRequest: mockRestRequest, + action: async (admin: AdminApiContext, session: Session) => + admin.rest.resources.Customer.all({session}), + }, + { + testGroup: 'GraphQL client', + mockRequest: mockGraphqlRequest(), + action: async (admin: AdminApiContext, _session: Session) => + admin.graphql('{ shop { name } }'), + }, + { + testGroup: 'GraphQL client with options', + mockRequest: mockGraphqlRequest('2021-01' as ApiVersion), + action: async (admin: AdminApiContext, _session: Session) => + admin.graphql( + 'mutation myMutation($ID: String!) { shop(ID: $ID) { name } }', + { + variables: {ID: '123'}, + apiVersion: '2021-01' as ApiVersion, + headers: {custom: 'header'}, + tries: 2, + }, + ), + }, + ])( + '$testGroup re-authentication', + ({testGroup: _testGroup, mockRequest, action}) => { + it('redirects to auth when request receives a 401 response and not embedded', async () => { + // GIVEN + const {admin, session} = await setUpNonEmbeddedFlow(); + const requestMock = await mockRequest(401); + + // WHEN + const response = await getThrownResponse( + async () => action(admin, session), + requestMock, + ); + + // THEN + expect(response.status).toEqual(302); + + const {hostname, pathname} = new URL(response.headers.get('Location')!); + expect(hostname).toEqual(TEST_SHOP); + expect(pathname).toEqual('/admin/oauth/authorize'); + }); + + it('redirects to exit iframe when request receives a 401 response and embedded', async () => { + // GIVEN + const {admin, session} = await setUpEmbeddedFlow(); + const requestMock = await mockRequest(401); + + // WHEN + const response = await getThrownResponse( + async () => action(admin, session), + requestMock, + ); + + // THEN + expectExitIframeRedirect(response); + }); + + it('returns app bridge redirection headers when request receives a 401 response on fetch requests', async () => { + // GIVEN + const {admin, session} = await setUpFetchFlow(); + const requestMock = await mockRequest(401); + + // WHEN + const response = await getThrownResponse( + async () => action(admin, session), + requestMock, + ); + + // THEN + expect(response.status).toEqual(401); + + const {origin, pathname, searchParams} = new URL( + response.headers.get(REAUTH_URL_HEADER)!, + ); + expect(origin).toEqual(APP_URL); + expect(pathname).toEqual('/auth'); + expect(searchParams.get('shop')).toEqual(TEST_SHOP); + }); + }, + ); +}); + +async function setUpEmbeddedFlow() { + const shopify = shopifyApp( + testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + restResources, + }), + ); + const expectedSession = await setUpValidSession(shopify.sessionStorage); + + const {token} = getJwt(); + const request = new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ); + + return { + shopify, + expectedSession, + ...(await shopify.authenticate.admin(request)), + }; +} + +async function setUpFetchFlow() { + const shopify = shopifyApp( + testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + restResources, + }), + ); + await setUpValidSession(shopify.sessionStorage); + + const {token} = getJwt(); + const request = new Request(APP_URL, { + headers: {Authorization: `Bearer ${token}`}, + }); + + return { + shopify, + ...(await shopify.authenticate.admin(request)), + }; +} + +async function setUpNonEmbeddedFlow() { + const shopify = shopifyApp( + testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + restResources, + isEmbeddedApp: false, + }), + ); + const session = await setUpValidSession(shopify.sessionStorage); + + const request = new Request(`${APP_URL}?shop=${TEST_SHOP}`); + signRequestCookie({ + request, + cookieName: SESSION_COOKIE_NAME, + cookieValue: session.id, + }); + + return { + shopify, + ...(await shopify.authenticate.admin(request)), + }; +} + +async function mockRestRequest(status) { + const requestMock = new Request( + `https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/customers.json`, + ); + + await mockExternalRequest({ + request: requestMock, + response: new Response('{}', {status}), + }); + + return requestMock; +} + +function mockGraphqlRequest(apiVersion = LATEST_API_VERSION) { + return async function (status = 401) { + const requestMock = new Request( + `https://${TEST_SHOP}/admin/api/${apiVersion}/graphql.json`, + {method: 'POST'}, + ); + + await mockExternalRequest({ + request: requestMock, + response: new Response(undefined, {status}), + }); + + return requestMock; + }; +} diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-callback-path.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-callback-path.test.ts new file mode 100644 index 0000000000..fac4593afd --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-callback-path.test.ts @@ -0,0 +1,396 @@ +import {HashFormat, createSHA256HMAC} from '@shopify/shopify-api/runtime'; + +import {shopifyApp} from '../../../../..'; +import { + BASE64_HOST, + TEST_SHOP, + getThrownResponse, + signRequestCookie, + testConfig, + mockExternalRequest, + APP_URL, +} from '../../../../../__test-helpers'; + +describe('authorize.admin auth callback path', () => { + describe.each([true, false])('when isEmbeddedApp: %s', (isEmbeddedApp) => { + describe('errors', () => { + test('throws an error if the shop param is missing', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(getCallbackUrl(config)), + ); + + // THEN + expect(response.status).toBe(400); + expect(await response.text()).toBe('Shop param is invalid'); + }); + + test('throws an error if the shop param is not valid', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(`${getCallbackUrl(config)}?shop=invalid`), + ); + + // THEN + expect(response.status).toBe(400); + expect(await response.text()).toBe('Shop param is invalid'); + }); + + test('throws an 302 Response to begin auth if CookieNotFound error', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + const callbackUrl = getCallbackUrl(config); + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(`${callbackUrl}?shop=${TEST_SHOP}`), + ); + + // THEN + const {searchParams, hostname} = new URL( + response.headers.get('location')!, + ); + + expect(response.status).toBe(302); + expect(hostname).toBe(TEST_SHOP); + expect(searchParams.get('client_id')).toBe(config.apiKey); + expect(searchParams.get('scope')).toBe(config.scopes!.toString()); + expect(searchParams.get('redirect_uri')).toBe(callbackUrl); + expect(searchParams.get('state')).toStrictEqual(expect.any(String)); + }); + + test('throws a 400 if there is no HMAC param', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + const state = 'nonce'; + const request = new Request( + `${getCallbackUrl(config)}?shop=${TEST_SHOP}&state=${state}`, + ); + + signRequestCookie({ + request, + cookieName: 'shopify_app_state', + cookieValue: state, + }); + + const response = await getThrownResponse( + shopify.authenticate.admin, + request, + ); + + // THEN + expect(response.status).toBe(400); + expect(response.statusText).toBe('Invalid OAuth Request'); + }); + + test('throws a 400 if the HMAC param is invalid', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + const state = 'nonce'; + const request = new Request( + `${getCallbackUrl( + config, + )}?shop=${TEST_SHOP}&state=${state}&hmac=invalid`, + ); + + signRequestCookie({ + request, + cookieName: 'shopify_app_state', + cookieValue: state, + }); + + const response = await getThrownResponse( + shopify.authenticate.admin, + request, + ); + + // THEN + expect(response.status).toBe(400); + }); + + test('throws a 500 if any other errors are thrown', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp({ + ...config, + hooks: { + afterAuth: () => { + throw new Error('test'); + }, + }, + }); + + // WHEN + await mockCodeExchangeRequest('offline'); + const response = await getThrownResponse( + shopify.authenticate.admin, + await getValidCallbackRequest(config), + ); + + // THEN + expect(response.status).toBe(500); + }); + }); + + describe('Success states', () => { + test('Exchanges the code for a token and saves it to SessionStorage', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + await mockCodeExchangeRequest('offline'); + const response = await getThrownResponse( + shopify.authenticate.admin, + await getValidCallbackRequest(config), + ); + + // THEN + const [session] = await config.sessionStorage!.findSessionsByShop( + TEST_SHOP, + ); + + expect(session).toMatchObject({ + accessToken: '123abc', + id: `offline_${TEST_SHOP}`, + isOnline: false, + scope: 'read_products', + shop: TEST_SHOP, + state: 'nonce', + }); + }); + + test('throws an 302 Response to begin auth if token was offline and useOnlineTokens is true', async () => { + // GIVEN + const config = testConfig({ + useOnlineTokens: true, + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + await mockCodeExchangeRequest('offline'); + const response = await getThrownResponse( + shopify.authenticate.admin, + await getValidCallbackRequest(config), + ); + + // THEN + const {searchParams, hostname} = new URL( + response.headers.get('location')!, + ); + + expect(response.status).toBe(302); + expect(hostname).toBe(TEST_SHOP); + expect(searchParams.get('client_id')).toBe(config.apiKey); + expect(searchParams.get('scope')).toBe(config.scopes!.toString()); + expect(searchParams.get('redirect_uri')).toBe(getCallbackUrl(config)); + expect(searchParams.get('state')).toStrictEqual(expect.any(String)); + }); + + test('Does not throw a 302 Response to begin auth if token was online', async () => { + // GIVEN + const config = testConfig({ + useOnlineTokens: true, + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + await mockCodeExchangeRequest('online'); + const response = await getThrownResponse( + shopify.authenticate.admin, + await getValidCallbackRequest(config), + ); + + // THEN + const base = `http://${APP_URL}`; + const {hostname} = new URL(response.headers.get('location')!, base); + expect(hostname).not.toBe(TEST_SHOP); + }); + + test('Runs the afterAuth hooks passing', async () => { + // GIVEN + const afterAuthMock = jest.fn(); + const config = testConfig({ + hooks: { + afterAuth: afterAuthMock, + }, + future: {unstable_newEmbeddedAuthStrategy: !isEmbeddedApp}, + isEmbeddedApp, + }); + const shopify = shopifyApp(config); + + // WHEN + await mockCodeExchangeRequest(); + const response = await getThrownResponse( + shopify.authenticate.admin, + await getValidCallbackRequest(config), + ); + + // THEN + expect(afterAuthMock).toHaveBeenCalledTimes(1); + }); + + if (isEmbeddedApp) { + test('throws a 302 response to the embedded app URL', async () => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }); + const shopify = shopifyApp(config); + + // WHEN + await mockCodeExchangeRequest('offline'); + const response = await getThrownResponse( + shopify.authenticate.admin, + await getValidCallbackRequest(config), + ); + + // THEN + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe( + 'https://totally-real-host.myshopify.io/apps/testApiKey', + ); + }); + } else { + test('throws a 302 to /', async () => { + // GIVEN + const config = testConfig({ + isEmbeddedApp: false, + }); + const shopify = shopifyApp(config); + + // WHEN + await mockCodeExchangeRequest('offline'); + const request = await getValidCallbackRequest(config); + const response = await getThrownResponse( + shopify.authenticate.admin, + request, + ); + + // THEN + const url = new URL(request.url); + const host = url.searchParams.get('host'); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe( + `/?shop=${TEST_SHOP}&host=${host}`, + ); + expect(response.headers.get('set-cookie')).toBe( + [ + 'shopify_app_state=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT', + `shopify_app_session=offline_${TEST_SHOP};sameSite=lax; secure=true; path=/`, + 'shopify_app_session.sig=0qSrbSUpq8Cr+fev917WGyO1IU3Py1fTwZukcHd4hVE=;sameSite=lax; secure=true; path=/', + ].join(', '), + ); + }); + } + }); + }); +}); + +function getCallbackUrl(appConfig: ReturnType) { + return `${appConfig.appUrl}/auth/callback`; +} + +async function getValidCallbackRequest(config: ReturnType) { + const cookieName = 'shopify_app_state'; + const state = 'nonce'; + const code = 'code_from_shopify'; + const now = Math.trunc(Date.now() / 1000) - 2; + const queryParams = `code=${code}&host=${BASE64_HOST}&shop=${TEST_SHOP}&state=${state}×tamp=${now}`; + const hmac = await createSHA256HMAC( + config.apiSecretKey, + queryParams, + HashFormat.Hex, + ); + + const request = new Request( + `${getCallbackUrl(config)}?${queryParams}&hmac=${hmac}`, + ); + + signRequestCookie({ + request, + cookieName, + cookieValue: state, + }); + + return request; +} + +async function mockCodeExchangeRequest( + tokenType: 'online' | 'offline' = 'offline', +) { + const responseBody = { + access_token: '123abc', + scope: 'read_products', + }; + + await mockExternalRequest({ + request: new Request(`https://${TEST_SHOP}/admin/oauth/access_token`, { + method: 'POST', + }), + response: + tokenType === 'offline' + ? new Response(JSON.stringify(responseBody)) + : new Response( + JSON.stringify({ + ...responseBody, + expires_in: Math.trunc(Date.now() / 1000) + 3600, + associated_user_scope: 'read_products', + associated_user: { + id: 902541635, + first_name: 'John', + last_name: 'Smith', + email: 'john@example.com', + email_verified: true, + account_owner: true, + locale: 'en', + collaborator: false, + }, + }), + ), + }); +} diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/auth-callback-path.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/auth-callback-path.test.ts deleted file mode 100644 index 2d637d4833..0000000000 --- a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/auth-callback-path.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import {HashFormat, createSHA256HMAC} from '@shopify/shopify-api/runtime'; - -import {shopifyApp} from '../../../../../..'; -import { - BASE64_HOST, - TEST_SHOP, - getThrownResponse, - signRequestCookie, - testConfig, - mockExternalRequest, -} from '../../../../../../__test-helpers'; - -describe('authorize.admin auth callback path', () => { - describe('errors', () => { - test('throws an error if the shop param is missing', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - const response = await getThrownResponse( - shopify.authenticate.admin, - new Request(getCallbackUrl(config)), - ); - - // THEN - expect(response.status).toBe(400); - expect(await response.text()).toBe('Shop param is invalid'); - }); - - test('throws an error if the shop param is not valid', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - const response = await getThrownResponse( - shopify.authenticate.admin, - new Request(`${getCallbackUrl(config)}?shop=invalid`), - ); - - // THEN - expect(response.status).toBe(400); - expect(await response.text()).toBe('Shop param is invalid'); - }); - - test('throws an 302 Response to begin auth if CookieNotFound error', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - const callbackUrl = getCallbackUrl(config); - const response = await getThrownResponse( - shopify.authenticate.admin, - new Request(`${callbackUrl}?shop=${TEST_SHOP}`), - ); - - // THEN - const {searchParams, hostname} = new URL( - response.headers.get('location')!, - ); - - expect(response.status).toBe(302); - expect(hostname).toBe(TEST_SHOP); - expect(searchParams.get('client_id')).toBe(config.apiKey); - expect(searchParams.get('scope')).toBe(config.scopes!.toString()); - expect(searchParams.get('redirect_uri')).toBe(callbackUrl); - expect(searchParams.get('state')).toStrictEqual(expect.any(String)); - }); - - test('throws a 400 if there is no HMAC param', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - const state = 'nonce'; - const request = new Request( - `${getCallbackUrl(config)}?shop=${TEST_SHOP}&state=${state}`, - ); - - signRequestCookie({ - request, - cookieName: 'shopify_app_state', - cookieValue: state, - }); - - const response = await getThrownResponse( - shopify.authenticate.admin, - request, - ); - - // THEN - expect(response.status).toBe(400); - expect(response.statusText).toBe('Invalid OAuth Request'); - }); - - test('throws a 400 if the HMAC param is invalid', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - const state = 'nonce'; - const request = new Request( - `${getCallbackUrl( - config, - )}?shop=${TEST_SHOP}&state=${state}&hmac=invalid`, - ); - - signRequestCookie({ - request, - cookieName: 'shopify_app_state', - cookieValue: state, - }); - - const response = await getThrownResponse( - shopify.authenticate.admin, - request, - ); - - // THEN - expect(response.status).toBe(400); - }); - - test('throws a 500 if any other errors are thrown', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp({ - ...config, - hooks: { - afterAuth: () => { - throw new Error('test'); - }, - }, - }); - - // WHEN - await mockCodeExchangeRequest('offline'); - const response = await getThrownResponse( - shopify.authenticate.admin, - await getValidCallbackRequest(config), - ); - - // THEN - expect(response.status).toBe(500); - }); - }); - - describe('Success states', () => { - test('Exchanges the code for a token and saves it to SessionStorage', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - await mockCodeExchangeRequest('offline'); - const response = await getThrownResponse( - shopify.authenticate.admin, - await getValidCallbackRequest(config), - ); - - // THEN - const [session] = await config.sessionStorage!.findSessionsByShop( - TEST_SHOP, - ); - - expect(session).toMatchObject({ - accessToken: '123abc', - id: `offline_${TEST_SHOP}`, - isOnline: false, - scope: 'read_products', - shop: TEST_SHOP, - state: 'nonce', - }); - }); - - test('throws an 302 Response to begin auth if token was offline and useOnlineTokens is true', async () => { - // GIVEN - const config = testConfig({useOnlineTokens: true}); - const shopify = shopifyApp(config); - - // WHEN - await mockCodeExchangeRequest('offline'); - const response = await getThrownResponse( - shopify.authenticate.admin, - await getValidCallbackRequest(config), - ); - - // THEN - const {searchParams, hostname} = new URL( - response.headers.get('location')!, - ); - - expect(response.status).toBe(302); - expect(hostname).toBe(TEST_SHOP); - expect(searchParams.get('client_id')).toBe(config.apiKey); - expect(searchParams.get('scope')).toBe(config.scopes!.toString()); - expect(searchParams.get('redirect_uri')).toBe(getCallbackUrl(config)); - expect(searchParams.get('state')).toStrictEqual(expect.any(String)); - }); - - test('Does not throw a 302 Response to begin auth if token was online', async () => { - // GIVEN - const config = testConfig({useOnlineTokens: true}); - const shopify = shopifyApp(config); - - // WHEN - await mockCodeExchangeRequest('online'); - const response = await getThrownResponse( - shopify.authenticate.admin, - await getValidCallbackRequest(config), - ); - - // THEN - const {hostname} = new URL(response.headers.get('location')!); - expect(hostname).not.toBe(TEST_SHOP); - }); - - test('Runs the afterAuth hooks passing', async () => { - // GIVEN - const afterAuthMock = jest.fn(); - const config = testConfig({ - hooks: { - afterAuth: afterAuthMock, - }, - }); - const shopify = shopifyApp(config); - - // WHEN - await mockCodeExchangeRequest(); - const response = await getThrownResponse( - shopify.authenticate.admin, - await getValidCallbackRequest(config), - ); - - // THEN - expect(afterAuthMock).toHaveBeenCalledTimes(1); - }); - - test('throws a 302 response to the emebdded app URL if isEmbeddedApp is true', async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(config); - - // WHEN - await mockCodeExchangeRequest('offline'); - const response = await getThrownResponse( - shopify.authenticate.admin, - await getValidCallbackRequest(config), - ); - - // THEN - expect(response.status).toBe(302); - expect(response.headers.get('location')).toBe( - 'https://totally-real-host.myshopify.io/apps/testApiKey', - ); - }); - - test('throws a 302 to / if embedded is not true', async () => { - // GIVEN - const config = testConfig({ - isEmbeddedApp: false, - }); - const shopify = shopifyApp(config); - - // WHEN - await mockCodeExchangeRequest('offline'); - const request = await getValidCallbackRequest(config); - const response = await getThrownResponse( - shopify.authenticate.admin, - request, - ); - - // THEN - const url = new URL(request.url); - const host = url.searchParams.get('host'); - expect(response.status).toBe(302); - expect(response.headers.get('location')).toBe( - `/?shop=${TEST_SHOP}&host=${host}`, - ); - expect(response.headers.get('set-cookie')).toBe( - [ - 'shopify_app_state=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT', - `shopify_app_session=offline_${TEST_SHOP};sameSite=lax; secure=true; path=/`, - 'shopify_app_session.sig=0qSrbSUpq8Cr+fev917WGyO1IU3Py1fTwZukcHd4hVE=;sameSite=lax; secure=true; path=/', - ].join(', '), - ); - }); - }); -}); - -function getCallbackUrl(appConfig: ReturnType) { - return `${appConfig.appUrl}/auth/callback`; -} - -async function getValidCallbackRequest(config: ReturnType) { - const cookieName = 'shopify_app_state'; - const state = 'nonce'; - const code = 'code_from_shopify'; - const now = Math.trunc(Date.now() / 1000) - 2; - const queryParams = `code=${code}&host=${BASE64_HOST}&shop=${TEST_SHOP}&state=${state}×tamp=${now}`; - const hmac = await createSHA256HMAC( - config.apiSecretKey, - queryParams, - HashFormat.Hex, - ); - - const request = new Request( - `${getCallbackUrl(config)}?${queryParams}&hmac=${hmac}`, - ); - - signRequestCookie({ - request, - cookieName, - cookieValue: state, - }); - - return request; -} - -async function mockCodeExchangeRequest( - tokenType: 'online' | 'offline' = 'offline', -) { - const responseBody = { - access_token: '123abc', - scope: 'read_products', - }; - - await mockExternalRequest({ - request: new Request(`https://${TEST_SHOP}/admin/oauth/access_token`, { - method: 'POST', - }), - response: - tokenType === 'offline' - ? new Response(JSON.stringify(responseBody)) - : new Response( - JSON.stringify({ - ...responseBody, - expires_in: Math.trunc(Date.now() / 1000) + 3600, - associated_user_scope: 'read_products', - associated_user: { - id: 902541635, - first_name: 'John', - last_name: 'Smith', - email: 'john@example.com', - email_verified: true, - account_owner: true, - locale: 'en', - collaborator: false, - }, - }), - ), - }); -} diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/auth-path.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-path.test.ts similarity index 78% rename from packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/auth-path.test.ts rename to packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-path.test.ts index 3108e1f569..d3b78c6ce9 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/auth-path.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-path.test.ts @@ -1,4 +1,4 @@ -import {shopifyApp} from '../../../../../..'; +import {shopifyApp} from '../../../../..'; import { APP_URL, TEST_SHOP, @@ -6,12 +6,14 @@ import { expectExitIframeRedirect, getThrownResponse, testConfig, -} from '../../../../../../__test-helpers'; +} from '../../../../../__test-helpers'; describe('authorize.admin auth path', () => { test('throws an 400 Response if the shop param is missing', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + }); const shopify = shopifyApp(config); // WHEN @@ -27,7 +29,9 @@ describe('authorize.admin auth path', () => { test('throws an 400 Response if the shop param is invalid', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + }); const shopify = shopifyApp(config); // WHEN @@ -43,7 +47,9 @@ describe('authorize.admin auth path', () => { test('throws an 302 Response to begin auth', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + }); const shopify = shopifyApp(config); // WHEN @@ -59,7 +65,9 @@ describe('authorize.admin auth path', () => { test('redirects to exit-iframe when loading the auth path while in an iframe request', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + }); const shopify = shopifyApp(config); // WHEN diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/authenticate.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/authenticate.test.ts similarity index 82% rename from packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/authenticate.test.ts rename to packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/authenticate.test.ts index 5777a663bb..93ac46f027 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/authenticate.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/authenticate.test.ts @@ -1,6 +1,6 @@ import {SESSION_COOKIE_NAME, Session} from '@shopify/shopify-api'; -import {shopifyApp} from '../../../../../..'; +import {shopifyApp} from '../../../../..'; import { APP_URL, BASE64_HOST, @@ -12,13 +12,19 @@ import { setUpValidSession, testConfig, signRequestCookie, -} from '../../../../../../__test-helpers'; +} from '../../../../../__test-helpers'; describe('authenticate', () => { describe('errors', () => { it('redirects to exit-iframe if app is embedded and the session is no longer valid for the id_token when embedded', async () => { // GIVEN - const shopify = shopifyApp(testConfig({scopes: ['otherTestScope']})); + const shopify = shopifyApp( + testConfig({ + scopes: ['otherTestScope'], + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }), + ); await setUpValidSession(shopify.sessionStorage); // WHEN @@ -37,8 +43,10 @@ describe('authenticate', () => { // manageAccessToken or ensureInstalledOnShop it('redirects to auth if there is no session cookie for non-embedded apps when at the top level', async () => { // GIVEN - const config = testConfig(); - const shopify = shopifyApp(testConfig({isEmbeddedApp: false})); + const config = testConfig({ + isEmbeddedApp: false, + }); + const shopify = shopifyApp(config); await setUpValidSession(shopify.sessionStorage); // WHEN @@ -58,8 +66,8 @@ describe('authenticate', () => { it('redirects to auth if the session is no longer valid for non-embedded apps when at the top level', async () => { // GIVEN const config = testConfig({ - isEmbeddedApp: false, scopes: ['otherTestScope'], + isEmbeddedApp: false, }); const shopify = shopifyApp(config); const session = await setUpValidSession(shopify.sessionStorage); @@ -89,15 +97,20 @@ describe('authenticate', () => { (isOnline) => { it('returns the context if the session is valid and the app is embedded', async () => { // GIVEN - const shopify = shopifyApp(testConfig({useOnlineTokens: isOnline})); + const shopify = shopifyApp( + testConfig({ + useOnlineTokens: isOnline, + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }), + ); let testSession: Session; testSession = await setUpValidSession(shopify.sessionStorage); if (isOnline) { - testSession = await setUpValidSession( - shopify.sessionStorage, + testSession = await setUpValidSession(shopify.sessionStorage, { isOnline, - ); + }); } // WHEN @@ -115,15 +128,18 @@ describe('authenticate', () => { it('returns the context if the session is valid and the app is not embedded', async () => { // GIVEN - const shopify = shopifyApp(testConfig({isEmbeddedApp: false})); + const shopify = shopifyApp( + testConfig({ + isEmbeddedApp: false, + }), + ); let testSession: Session; testSession = await setUpValidSession(shopify.sessionStorage); if (isOnline) { - testSession = await setUpValidSession( - shopify.sessionStorage, + testSession = await setUpValidSession(shopify.sessionStorage, { isOnline, - ); + }); } // WHEN diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/ensure-installed-on-shop.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/ensure-installed-on-shop.test.ts similarity index 81% rename from packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/ensure-installed-on-shop.test.ts rename to packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/ensure-installed-on-shop.test.ts index 22b7e53130..7f33a0182d 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/auth-code-flow/ensure-installed-on-shop.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/ensure-installed-on-shop.test.ts @@ -1,6 +1,6 @@ import {LogSeverity, SESSION_COOKIE_NAME} from '@shopify/shopify-api'; -import {shopifyApp} from '../../../../../..'; +import {shopifyApp} from '../../../../..'; import { API_KEY, APP_URL, @@ -13,16 +13,16 @@ import { getJwt, getThrownResponse, setUpValidSession, - testConfig, signRequestCookie, mockExternalRequest, -} from '../../../../../../__test-helpers'; + testConfig, +} from '../../../../../__test-helpers'; describe('authorize.admin doc request path', () => { describe('errors', () => { it('redirects to auth when not embedded and there is no offline session', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({isEmbeddedApp: false}); const shopify = shopifyApp(config); // WHEN @@ -37,7 +37,12 @@ describe('authorize.admin doc request path', () => { it('redirects to exit-iframe when embedded and there is no offline session', async () => { // GIVEN - const shopify = shopifyApp(testConfig()); + const shopify = shopifyApp( + testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }), + ); // WHEN const response = await getThrownResponse( @@ -53,7 +58,10 @@ describe('authorize.admin doc request path', () => { it('redirects to auth when not embedded on an embedded app, and the API token is invalid', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }); const shopify = shopifyApp(config); await setUpValidSession(shopify.sessionStorage); @@ -74,7 +82,10 @@ describe('authorize.admin doc request path', () => { it('returns non-401 codes when not embedded on an embedded app and the request fails', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }); const shopify = shopifyApp(config); await setUpValidSession(shopify.sessionStorage); @@ -102,7 +113,10 @@ describe('authorize.admin doc request path', () => { it('returns a 500 when not embedded on an embedded app and the request fails', async () => { // GIVEN - const config = testConfig(); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }); const shopify = shopifyApp(config); await setUpValidSession(shopify.sessionStorage); @@ -127,34 +141,14 @@ describe('authorize.admin doc request path', () => { ); }); - it("redirects to the embedded app URL if there is a valid session but the app isn't embedded yet", async () => { - // GIVEN - const config = testConfig(); - const shopify = shopifyApp(testConfig()); - await setUpValidSession(shopify.sessionStorage); - - await mockExternalRequest({ - request: new Request(GRAPHQL_URL, {method: 'POST'}), - response: new Response(undefined, {status: 200}), - }); - - // WHEN - const response = await getThrownResponse( - shopify.authenticate.admin, - new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`), - ); - - // THEN - const {hostname, pathname} = new URL(response.headers.get('location')!); - - expect(response.status).toBe(302); - expect(hostname).toBe(SHOPIFY_HOST); - expect(pathname).toBe(`/apps/${API_KEY}`); - }); - it('redirects to exit-iframe if app is embedded and there is no session for the id_token when embedded', async () => { // GIVEN - const shopify = shopifyApp(testConfig()); + const shopify = shopifyApp( + testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + isEmbeddedApp: true, + }), + ); await setUpValidSession(shopify.sessionStorage); const otherShopDomain = 'other-shop.myshopify.io'; @@ -171,11 +165,12 @@ describe('authorize.admin doc request path', () => { expectExitIframeRedirect(response, {shop: otherShopDomain}); }); - // manageAccessToken or ensureInstalledOnShop it('redirects to auth if there is no session cookie for non-embedded apps when at the top level', async () => { // GIVEN - const config = testConfig(); - const shopify = shopifyApp(testConfig({isEmbeddedApp: false})); + const config = testConfig({ + isEmbeddedApp: false, + }); + const shopify = shopifyApp(config); await setUpValidSession(shopify.sessionStorage); // WHEN @@ -218,7 +213,6 @@ describe('authorize.admin doc request path', () => { }); }); - // manageAccessToken & ensureInstalledOnShop it('loads a session from the cookie from a request with no search params when not embedded', async () => { // GIVEN const shopify = shopifyApp(testConfig({isEmbeddedApp: false})); diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/session-token-header-path.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/session-token-header-path.test.ts new file mode 100644 index 0000000000..56d14ae7ea --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/auth-code-flow/session-token-header-path.test.ts @@ -0,0 +1,79 @@ +import {SESSION_COOKIE_NAME, Session} from '@shopify/shopify-api'; + +import {shopifyApp} from '../../../../..'; +import { + APP_URL, + BASE64_HOST, + TEST_SHOP, + getJwt, + getThrownResponse, + setUpValidSession, + testConfig, +} from '../../../../../__test-helpers'; +import {REAUTH_URL_HEADER} from '../../../../const'; + +describe('authorize.session token header path', () => { + describe('errors', () => { + describe.each([true, false])('when isOnline: %s', (isOnline) => { + it(`returns app bridge redirection headers if there is no session`, async () => { + // GIVEN + const shopify = shopifyApp( + testConfig({ + useOnlineTokens: isOnline, + future: {unstable_newEmbeddedAuthStrategy: false}, + }), + ); + + // WHEN + const {token} = getJwt(); + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`, { + headers: {Authorization: `Bearer ${token}`}, + }), + ); + + // THEN + const {origin, pathname, searchParams} = new URL( + response.headers.get(REAUTH_URL_HEADER)!, + ); + + expect(response.status).toBe(401); + expect(origin).toBe(APP_URL); + expect(pathname).toBe('/auth'); + expect(searchParams.get('shop')).toBe(TEST_SHOP); + }); + + it(`returns app bridge redirection headers if the session is no longer valid`, async () => { + // GIVEN + const shopify = shopifyApp( + testConfig({ + useOnlineTokens: isOnline, + scopes: ['otherTestScope'], + future: {unstable_newEmbeddedAuthStrategy: false}, + }), + ); + // The session scopes don't match the configured scopes, so it needs to be reset + await setUpValidSession(shopify.sessionStorage, {isOnline}); + + // WHEN + const {token} = getJwt(); + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(`${APP_URL}?shop=${TEST_SHOP}&host=${BASE64_HOST}`, { + headers: {Authorization: `Bearer ${token}`}, + }), + ); + + // THEN + const {origin, pathname, searchParams} = new URL( + response.headers.get(REAUTH_URL_HEADER)!, + ); + expect(response.status).toBe(401); + expect(origin).toBe(APP_URL); + expect(pathname).toBe('/auth'); + expect(searchParams.get('shop')).toBe(TEST_SHOP); + }); + }); + }); +}); diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/token-exchange/admin-client.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/token-exchange/admin-client.test.ts new file mode 100644 index 0000000000..d3c6f5ff49 --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/token-exchange/admin-client.test.ts @@ -0,0 +1,189 @@ +import { + ApiVersion, + HttpMaxRetriesError, + LATEST_API_VERSION, + SESSION_COOKIE_NAME, + Session, +} from '@shopify/shopify-api'; +import {restResources} from '@shopify/shopify-api/rest/admin/2023-04'; + +import { + APP_URL, + BASE64_HOST, + TEST_SHOP, + expectExitIframeRedirect, + getJwt, + getThrownResponse, + setUpValidSession, + signRequestCookie, + testConfig, + mockExternalRequest, + expectAdminApiClient, +} from '../../../../../__test-helpers'; +import {shopifyApp} from '../../../../..'; +import { + REAUTH_URL_HEADER, + RETRY_INVALID_SESSION_HEADER, +} from '../../../../const'; +import {AdminApiContext} from '../../../../../clients'; + +describe('admin.authenticate context', () => { + expectAdminApiClient(async () => { + const { + admin, + expectedSession, + session: actualSession, + } = await setUpDocumentFlow(); + + return {admin, expectedSession, actualSession}; + }); + describe.each([ + { + testGroup: 'REST client', + mockRequest: mockRestRequest, + action: async (admin: AdminApiContext, _session: Session) => + admin.rest.get({path: '/customers.json'}), + }, + { + testGroup: 'REST resources', + mockRequest: mockRestRequest, + action: async (admin: AdminApiContext, session: Session) => + admin.rest.resources.Customer.all({session}), + }, + { + testGroup: 'GraphQL client', + mockRequest: mockGraphqlRequest(), + action: async (admin: AdminApiContext, _session: Session) => + admin.graphql('{ shop { name } }'), + }, + { + testGroup: 'GraphQL client with options', + mockRequest: mockGraphqlRequest('2021-01' as ApiVersion), + action: async (admin: AdminApiContext, _session: Session) => + admin.graphql( + 'mutation myMutation($ID: String!) { shop(ID: $ID) { name } }', + { + variables: {ID: '123'}, + apiVersion: '2021-01' as ApiVersion, + headers: {custom: 'header'}, + tries: 2, + }, + ), + }, + ])( + '$testGroup re-authentication', + ({testGroup: _testGroup, mockRequest, action}) => { + it('redirects to exit bounce page when document request receives a 401 response', async () => { + // GIVEN + const {admin, session, shopify} = await setUpDocumentFlow(); + const requestMock = await mockRequest(); + + // WHEN + const response = await getThrownResponse( + async () => action(admin, session), + requestMock, + ); + + // THEN + expect(response.status).toBe(302); + + const {pathname} = new URL(response.headers.get('location')!, APP_URL); + expect(pathname).toBe('/auth/session-token'); + + expect( + await shopify.sessionStorage.loadSession(session.id), + ).toBeUndefined(); + }); + + it('returns 401 when receives a 401 response on fetch requests', async () => { + // GIVEN + const {admin, session, shopify} = await setUpFetchFlow(); + const requestMock = await mockRequest(); + + // WHEN + const response = await getThrownResponse( + async () => action(admin, session), + requestMock, + ); + + // THEN + expect(response.status).toEqual(401); + expect( + response.headers.get('X-Shopify-Retry-Invalid-Session-Request'), + ).toBeNull(); + + expect( + await shopify.sessionStorage.loadSession(session.id), + ).toBeUndefined(); + }); + }, + ); +}); + +async function setUpDocumentFlow() { + const shopify = shopifyApp( + testConfig({ + restResources, + }), + ); + const expectedSession = await setUpValidSession(shopify.sessionStorage); + + const {token} = getJwt(); + const request = new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ); + + return { + shopify, + expectedSession, + ...(await shopify.authenticate.admin(request)), + }; +} + +async function setUpFetchFlow() { + const shopify = shopifyApp( + testConfig({ + restResources, + }), + ); + await setUpValidSession(shopify.sessionStorage); + + const {token} = getJwt(); + const request = new Request(APP_URL, { + headers: {Authorization: `Bearer ${token}`}, + }); + + return { + shopify, + ...(await shopify.authenticate.admin(request)), + }; +} + +async function mockRestRequest(status = 401) { + const requestMock = new Request( + `https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/customers.json`, + ); + + await mockExternalRequest({ + request: requestMock, + response: new Response('{}', {status}), + }); + + return requestMock; +} + +function mockGraphqlRequest(apiVersion = LATEST_API_VERSION) { + return async function (status = 401) { + const requestMock = new Request( + `https://${TEST_SHOP}/admin/api/${apiVersion}/graphql.json`, + {method: 'POST'}, + ); + + await mockExternalRequest({ + request: requestMock, + response: new Response(undefined, {status}), + }); + + return requestMock; + }; +} diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/token-exchange/authenticate.test.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/token-exchange/authenticate.test.ts new file mode 100644 index 0000000000..563288e299 --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/__tests__/token-exchange/authenticate.test.ts @@ -0,0 +1,312 @@ +import {Session} from '@shopify/shopify-api'; + +import {shopifyApp} from '../../../../..'; +import { + API_KEY, + API_SECRET_KEY, + APP_URL, + BASE64_HOST, + TEST_SHOP, + getJwt, + getThrownResponse, + mockExternalRequest, + setUpValidSession, + testConfig, +} from '../../../../../__test-helpers'; + +const USER_ID = 902541635; + +describe('authenticate', () => { + it('performs token exchange when there is no offline session', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp(config); + + const {token} = getJwt(); + await mockTokenExchangeRequest(token, 'offline'); + + // WHEN + const {session} = await shopify.authenticate.admin( + new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + const [persistedSession] = await config.sessionStorage.findSessionsByShop( + TEST_SHOP, + ); + + expect(persistedSession).toEqual(session); + expect(session).toMatchObject({ + accessToken: '123abc-exchanged-from-session-token', + id: `offline_${TEST_SHOP}`, + isOnline: false, + scope: 'read_orders', + shop: TEST_SHOP, + state: '', + }); + }); + + it('performs token exchange when existing session is no longer valid', async () => { + // GIVEN + const config = testConfig({useOnlineTokens: true}); + const shopify = shopifyApp(config); + const anHourAgo = new Date(Date.now() - 1000 * 3600); + await setUpValidSession(shopify.sessionStorage, { + isOnline: true, + expires: anHourAgo, + }); + + const {token} = getJwt(); + await mockTokenExchangeRequest(token, 'offline'); + await mockTokenExchangeRequest(token, 'online'); + + // WHEN + const {session} = await shopify.authenticate.admin( + new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + const [_, onlineSession] = await config.sessionStorage.findSessionsByShop( + TEST_SHOP, + ); + + expect(onlineSession).toEqual(session); + expect(session).toMatchObject({ + accessToken: '123abc-exchanged-from-session-token', + id: `${TEST_SHOP}_${USER_ID}`, + isOnline: true, + scope: 'read_orders', + shop: TEST_SHOP, + state: '', + onlineAccessInfo: expect.any(Object), + }); + }); + + describe.each([true, false])( + 'existing sessions when isOnline: %s', + (isOnline) => { + it('returns the context if the session is valid', async () => { + // GIVEN + const shopify = shopifyApp(testConfig({useOnlineTokens: isOnline})); + + let testSession: Session; + testSession = await setUpValidSession(shopify.sessionStorage); + if (isOnline) { + testSession = await setUpValidSession(shopify.sessionStorage, { + isOnline, + }); + } + + // WHEN + const {token} = getJwt(); + const {admin, session} = await shopify.authenticate.admin( + new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + expect(session).toBe(testSession); + expect(admin.rest.session).toBe(testSession); + expect(session.isOnline).toEqual(isOnline); + }); + }, + ); + + test('redirects to bounce page on document request when receiving an invalid subject token response from token exchange API', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp(config); + + const {token} = getJwt(); + await mockInvalidTokenExchangeRequest('invalid_subject_token'); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + const {pathname, searchParams} = new URL( + response.headers.get('location')!, + APP_URL, + ); + + expect(response.status).toBe(302); + expect(pathname).toBe('/auth/session-token'); + expect(searchParams.get('shop')).toBe(TEST_SHOP); + expect(searchParams.get('host')).toBe(BASE64_HOST); + expect(searchParams.get('shopify-reload')).toBe( + `${APP_URL}/?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}`, + ); + }); + + test('throws 401 unauthorized on XHR request when receiving an invalid subject token response from token exchange API', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp(config); + + const {token} = getJwt(); + await mockInvalidTokenExchangeRequest('invalid_subject_token'); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(APP_URL, { + headers: { + Authorization: `Bearer ${token}`, + }, + }), + ); + + // THEN + expect(response.status).toBe(401); + expect( + response.headers.get('X-Shopify-Retry-Invalid-Session-Request'), + ).toEqual('1'); + }); + + test('throws 500 for any other error from token exchange API', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp(config); + + const {token} = getJwt(); + await mockInvalidTokenExchangeRequest('im_broke', 401); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request(APP_URL, { + headers: { + Authorization: `Bearer ${token}`, + }, + }), + ); + + // THEN + expect(response.status).toBe(500); + }); + + test('throws a 500 if afterAuth hook throws an error', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp({ + ...config, + hooks: { + afterAuth: () => { + throw new Error('test'); + }, + }, + }); + + const {token} = getJwt(); + await mockTokenExchangeRequest(token, 'offline'); + + // WHEN + const response = await getThrownResponse( + shopify.authenticate.admin, + new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + expect(response.status).toBe(500); + }); + + test('Runs the afterAuth hooks passing', async () => { + // GIVEN + const afterAuthMock = jest.fn(); + const config = testConfig({ + hooks: { + afterAuth: afterAuthMock, + }, + }); + const shopify = shopifyApp(config); + + const {token} = getJwt(); + await mockTokenExchangeRequest(token, 'offline'); + + // WHEN + const {session} = await shopify.authenticate.admin( + new Request( + `${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + expect(session).toBeDefined(); + expect(afterAuthMock).toHaveBeenCalledTimes(1); + }); +}); + +async function mockTokenExchangeRequest( + sessionToken, + tokenType: 'online' | 'offline' = 'offline', +) { + const responseBody = { + access_token: '123abc-exchanged-from-session-token', + scope: 'read_orders', + }; + + await mockExternalRequest({ + request: new Request(`https://${TEST_SHOP}/admin/oauth/access_token`, { + method: 'POST', + body: JSON.stringify({ + client_id: API_KEY, + client_secret: API_SECRET_KEY, + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: sessionToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + requested_token_type: + tokenType === 'offline' + ? 'urn:shopify:params:oauth:token-type:offline-access-token' + : 'urn:shopify:params:oauth:token-type:online-access-token', + }), + }), + response: + tokenType === 'offline' + ? new Response(JSON.stringify(responseBody)) + : new Response( + JSON.stringify({ + ...responseBody, + expires_in: Math.trunc(Date.now() / 1000) + 3600, + associated_user_scope: 'read_orders', + associated_user: { + id: USER_ID, + first_name: 'John', + last_name: 'Smith', + email: 'john@example.com', + email_verified: true, + account_owner: true, + locale: 'en', + collaborator: false, + }, + }), + ), + }); +} + +async function mockInvalidTokenExchangeRequest(error: string, status = 400) { + await mockExternalRequest({ + request: new Request(`https://${TEST_SHOP}/admin/oauth/access_token`, { + method: 'POST', + }), + response: new Response( + JSON.stringify({ + error, + }), + { + status, + }, + ), + }); +} diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/auth-code-flow.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/auth-code-flow.ts index 20b87cbad1..69a6dba4cc 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/auth-code-flow.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/auth-code-flow.ts @@ -12,6 +12,7 @@ import { import type {BasicParams} from '../../../types'; import { beginAuth, + handleClientErrorFactory, redirectToAuthPage, redirectToShopifyOrAppRoot, redirectWithExitIframe, @@ -20,8 +21,9 @@ import { } from '../helpers'; import {AppConfig} from '../../../config-types'; import {getSessionTokenHeader} from '../../helpers'; +import {HandleAdminClientError} from '../../../clients'; -import {AuthorizationStrategy} from './types'; +import {AuthorizationStrategy, SessionContext, OnErrorOptions} from './types'; export class AuthCodeFlowStrategy< Resources extends ShopifyRestResources = ShopifyRestResources, @@ -65,11 +67,12 @@ export class AuthCodeFlowStrategy< public async authenticate( request: Request, - session: Session | undefined, - shop: string, + sessionContext: SessionContext, ): Promise { const {api, config, logger} = this; + const {shop, session} = sessionContext; + if (!session) { logger.debug('No session found, redirecting to OAuth', {shop}); await redirectToAuthPage({config, logger, api}, request, shop); @@ -86,6 +89,22 @@ export class AuthCodeFlowStrategy< return session!; } + public handleClientError(request: Request): HandleAdminClientError { + const {api, config, logger} = this; + return handleClientErrorFactory({ + request, + onError: async ({session, error}: OnErrorOptions) => { + if (error.response.code === 401) { + throw await redirectToAuthPage( + {api, config, logger}, + request, + session.shop, + ); + } + }, + }); + } + private async ensureInstalledOnShop(request: Request) { const {api, config, logger} = this; @@ -179,6 +198,7 @@ export class AuthCodeFlowStrategy< {api, config, logger}, session, request, + this, ); throw await redirectToShopifyOrAppRoot( diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/token-exchange.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/token-exchange.ts new file mode 100644 index 0000000000..f1c0308ae0 --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/token-exchange.ts @@ -0,0 +1,169 @@ +import { + HttpResponseError, + InvalidJwtError, + RequestedTokenType, + Session, + Shopify, + ShopifyRestResources, +} from '@shopify/shopify-api'; + +import {AppConfig, AppConfigArg} from '../../../config-types'; +import { + BasicParams, + ApiConfigWithFutureFlags, + ApiFutureFlags, +} from '../../../types'; +import {respondToInvalidSessionToken} from '../../helpers'; +import {handleClientErrorFactory, triggerAfterAuthHook} from '../helpers'; +import {HandleAdminClientError} from '../../../clients'; + +import {AuthorizationStrategy, SessionContext, OnErrorOptions} from './types'; + +export class TokenExchangeStrategy + implements AuthorizationStrategy +{ + protected api: Shopify< + ApiConfigWithFutureFlags, + ShopifyRestResources, + ApiFutureFlags + >; + + protected config: AppConfig; + protected logger: Shopify['logger']; + + public constructor({api, config, logger}: BasicParams) { + this.api = api; + this.config = config; + this.logger = logger; + } + + public async respondToOAuthRequests(_request: Request): Promise {} + + public async authenticate( + request: Request, + sessionContext: SessionContext, + ): Promise { + const {api, config, logger} = this; + const {shop, session, sessionToken} = sessionContext; + + if (!sessionToken) throw new InvalidJwtError(); + + if (!session || session.isExpired()) { + logger.info('No valid session found'); + logger.info('Requesting offline access token'); + const {session: offlineSession} = await this.exchangeToken({ + request, + sessionToken, + shop, + requestedTokenType: RequestedTokenType.OfflineAccessToken, + }); + + await config.sessionStorage.storeSession(offlineSession); + + let newSession = offlineSession; + + if (config.useOnlineTokens) { + logger.info('Requesting online access token'); + const {session: onlineSession} = await this.exchangeToken({ + request, + sessionToken, + shop, + requestedTokenType: RequestedTokenType.OnlineAccessToken, + }); + + await config.sessionStorage.storeSession(onlineSession); + newSession = onlineSession; + } + + try { + await this.handleAfterAuthHook( + {api, config, logger}, + newSession, + request, + sessionToken, + ); + } catch (error) { + throw new Response(undefined, { + status: 500, + statusText: 'Internal Server Error', + }); + } + + return newSession; + } + + return session!; + } + + public handleClientError(request: Request): HandleAdminClientError { + const {api, config, logger} = this; + return handleClientErrorFactory({ + request, + onError: async ({session, error}: OnErrorOptions) => { + if (error.response.code === 401) { + config.sessionStorage.deleteSession(session.id); + + respondToInvalidSessionToken({ + params: {config, api, logger}, + request, + }); + } + }, + }); + } + + private async exchangeToken({ + request, + shop, + sessionToken, + requestedTokenType, + }: { + request: Request; + shop: string; + sessionToken: string; + requestedTokenType: RequestedTokenType; + }): Promise<{session: Session}> { + const {api, config, logger} = this; + + try { + return await api.auth.tokenExchange({ + sessionToken, + shop, + requestedTokenType, + }); + } catch (error) { + if ( + error instanceof InvalidJwtError || + (error instanceof HttpResponseError && + error.response.code === 400 && + error.response.body?.error === 'invalid_subject_token') + ) { + throw respondToInvalidSessionToken({ + params: {api, config, logger}, + request, + retryRequest: true, + }); + } + + throw new Response(undefined, { + status: 500, + statusText: 'Internal Server Error', + }); + } + } + + private async handleAfterAuthHook( + params: BasicParams, + session: Session, + request: Request, + sessionToken: string, + ) { + const {config} = params; + await config.idempotentPromiseHandler.handlePromise({ + promiseFunction: () => { + return triggerAfterAuthHook(params, session, request, this); + }, + identifier: sessionToken, + }); + } +} diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/types.ts b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/types.ts index aa472df4c0..f852a9d185 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/strategies/types.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/strategies/types.ts @@ -1,16 +1,29 @@ -import {JwtPayload, Session} from '@shopify/shopify-api'; +import {HttpResponseError, Session} from '@shopify/shopify-api'; -export interface SessionTokenContext { +import {HandleAdminClientError} from '../../../clients'; + +export interface SessionContext { shop: string; - sessionId?: string; - payload?: JwtPayload; + session?: Session; + sessionToken?: string; +} + +export interface OnErrorOptions { + request: Request; + session: Session; + error: HttpResponseError; +} + +export interface HandleClientErrorOptions { + request: Request; + onError?: ({session, error}: OnErrorOptions) => Promise; } export interface AuthorizationStrategy { respondToOAuthRequests: (request: Request) => Promise; authenticate: ( request: Request, - session: Session | undefined, - shop: string, + sessionContext: SessionContext, ) => Promise; + handleClientError: (request: Request) => HandleAdminClientError; } diff --git a/packages/shopify-app-remix/src/server/authenticate/admin/types.ts b/packages/shopify-app-remix/src/server/authenticate/admin/types.ts index 3000e937ef..96dedc4885 100644 --- a/packages/shopify-app-remix/src/server/authenticate/admin/types.ts +++ b/packages/shopify-app-remix/src/server/authenticate/admin/types.ts @@ -197,8 +197,3 @@ export type AuthenticateAdmin< Config extends AppConfigArg, Resources extends ShopifyRestResources = ShopifyRestResources, > = (request: Request) => Promise>; - -export interface SessionContext { - session: Session; - token?: JwtPayload; -} diff --git a/packages/shopify-app-remix/src/server/authenticate/const.ts b/packages/shopify-app-remix/src/server/authenticate/const.ts index 4aa2a4a218..481f931385 100644 --- a/packages/shopify-app-remix/src/server/authenticate/const.ts +++ b/packages/shopify-app-remix/src/server/authenticate/const.ts @@ -4,6 +4,10 @@ export const APP_BRIDGE_URL = export const REAUTH_URL_HEADER = 'X-Shopify-API-Request-Failure-Reauthorize-Url'; +export const RETRY_INVALID_SESSION_HEADER = { + 'X-Shopify-Retry-Invalid-Session-Request': '1', +}; + export const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization', diff --git a/packages/shopify-app-remix/src/server/authenticate/helpers/__tests__/idempotent-promise-handler.test.ts b/packages/shopify-app-remix/src/server/authenticate/helpers/__tests__/idempotent-promise-handler.test.ts new file mode 100644 index 0000000000..235ab2d986 --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/helpers/__tests__/idempotent-promise-handler.test.ts @@ -0,0 +1,104 @@ +import {ShopifyError} from '@shopify/shopify-api'; + +import {IdempotentPromiseHandler} from '../idempotent-promise-handler'; + +const mockFunction = jest.fn(); +async function promiseFunction() { + mockFunction(); +} + +afterEach(() => { + mockFunction.mockReset(); +}); + +describe('IdempotentPromiseHandler', () => { + it('runs the promise function only once for the same identifier', async () => { + // GIVEN + const promiseHandler = new IdempotentPromiseHandler(); + + // WHEN + promiseHandler.handlePromise({ + promiseFunction, + identifier: 'first-promise', + }); + + promiseHandler.handlePromise({ + promiseFunction, + identifier: 'first-promise', + }); + + // THEN + expect(mockFunction).toHaveBeenCalledTimes(1); + }); + + it('runs the promise function once for each identifier', async () => { + // GIVEN + // const promiseFunction = jest.fn(); + const promiseHandler = new IdempotentPromiseHandler(); + + // WHEN + promiseHandler.handlePromise({ + promiseFunction, + identifier: 'first-promise', + }); + + promiseHandler.handlePromise({ + promiseFunction, + identifier: 'second-promise', + }); + + // THEN + expect(mockFunction).toHaveBeenCalledTimes(2); + }); + + it('clears stale identifier from hash', async () => { + // GIVEN + const currentDate = Date.now(); + jest.useFakeTimers().setSystemTime(currentDate); + const promiseHandler = new IdempotentPromiseHandler() as any; + + // WHEN + promiseHandler.handlePromise({ + promiseFunction, + identifier: 'old-promise', + }); + + jest.useFakeTimers().setSystemTime(currentDate + 70000); + + await promiseHandler.handlePromise({ + promiseFunction, + identifier: 'new-promise', + }); + + expect(promiseHandler.identifiers.size).toBe(1); + }); + + it('clears stale identifier from hash even when promise fails', async () => { + // GIVEN + const promiseFunctionErr = () => { + throw new ShopifyError(); + }; + const currentDate = Date.now(); + jest.useFakeTimers().setSystemTime(currentDate); + const promiseHandler = new IdempotentPromiseHandler() as any; + + // WHEN + expect( + promiseHandler.handlePromise({ + promiseFunction: promiseFunctionErr, + identifier: 'old-promise', + }), + ).rejects.toThrow(); + + jest.useFakeTimers().setSystemTime(currentDate + 70000); + + expect( + promiseHandler.handlePromise({ + promiseFunction: promiseFunctionErr, + identifier: 'new-promise', + }), + ).rejects.toThrow(); + + expect(promiseHandler.identifiers.size).toBe(1); + }); +}); diff --git a/packages/shopify-app-remix/src/server/authenticate/helpers/idempotent-promise-handler.ts b/packages/shopify-app-remix/src/server/authenticate/helpers/idempotent-promise-handler.ts new file mode 100644 index 0000000000..e944546ad0 --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/helpers/idempotent-promise-handler.ts @@ -0,0 +1,45 @@ +export interface IdempotentHandlePromiseParams { + promiseFunction: () => Promise; + identifier: string; +} + +const IDENTIFIER_TTL_MS = 60000; + +export class IdempotentPromiseHandler { + protected identifiers: Map; + + constructor() { + this.identifiers = new Map(); + } + + async handlePromise({ + promiseFunction, + identifier, + }: IdempotentHandlePromiseParams): Promise { + try { + if (this.isPromiseRunnable(identifier)) { + await promiseFunction(); + } + } finally { + this.clearStaleIdentifiers(); + } + + return Promise.resolve(); + } + + private isPromiseRunnable(identifier: string) { + if (!this.identifiers.has(identifier)) { + this.identifiers.set(identifier, Date.now()); + return true; + } + return false; + } + + private async clearStaleIdentifiers() { + this.identifiers.forEach((date, identifier, map) => { + if (Date.now() - date > IDENTIFIER_TTL_MS) { + map.delete(identifier); + } + }); + } +} diff --git a/packages/shopify-app-remix/src/server/authenticate/helpers/index.ts b/packages/shopify-app-remix/src/server/authenticate/helpers/index.ts index 351221a750..383a37bd9a 100644 --- a/packages/shopify-app-remix/src/server/authenticate/helpers/index.ts +++ b/packages/shopify-app-remix/src/server/authenticate/helpers/index.ts @@ -5,3 +5,4 @@ export * from './validate-session-token'; export * from './get-session-token-header'; export * from './reject-bot-request'; export * from './respond-to-options-request'; +export * from './respond-to-invalid-session-token'; diff --git a/packages/shopify-app-remix/src/server/authenticate/helpers/respond-to-invalid-session-token.ts b/packages/shopify-app-remix/src/server/authenticate/helpers/respond-to-invalid-session-token.ts new file mode 100644 index 0000000000..8b215d781a --- /dev/null +++ b/packages/shopify-app-remix/src/server/authenticate/helpers/respond-to-invalid-session-token.ts @@ -0,0 +1,29 @@ +import {BasicParams} from 'src/server/types'; + +import {redirectToBouncePage} from '../admin/helpers/redirect-to-bounce-page'; +import {RETRY_INVALID_SESSION_HEADER} from '../const'; + +interface RespondToInvalidSessionTokenParams { + params: BasicParams; + request: Request; + retryRequest?: boolean; +} + +export function respondToInvalidSessionToken({ + params, + request, + retryRequest = false, +}: RespondToInvalidSessionTokenParams) { + const {api, logger, config} = params; + + const isDocumentRequest = !request.headers.get('authorization'); + if (isDocumentRequest) { + return redirectToBouncePage({api, logger, config}, new URL(request.url)); + } + + throw new Response(undefined, { + status: 401, + statusText: 'Unauthorized', + headers: retryRequest ? RETRY_INVALID_SESSION_HEADER : {}, + }); +} diff --git a/packages/shopify-app-remix/src/server/authenticate/login/__tests__/login.test.ts b/packages/shopify-app-remix/src/server/authenticate/login/__tests__/login.test.ts index ce957516ef..f7bd8de25b 100644 --- a/packages/shopify-app-remix/src/server/authenticate/login/__tests__/login.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/login/__tests__/login.test.ts @@ -2,6 +2,7 @@ import {LoginErrorType, shopifyApp} from '../../../index'; import { APP_URL, TEST_SHOP, + TEST_SHOP_NAME, getThrownResponse, testConfig, } from '../../../__test-helpers'; @@ -79,25 +80,63 @@ describe('login helper', () => { }, ); - it.each([ - {urlShop: null, formShop: TEST_SHOP, method: 'POST'}, - {urlShop: TEST_SHOP, formShop: null, method: 'GET'}, - {urlShop: null, formShop: 'test-shop', method: 'POST'}, - {urlShop: 'test-shop', formShop: null, method: 'GET'}, - {urlShop: null, formShop: 'test-shop.myshopify.com', method: 'POST'}, - {urlShop: 'test-shop.myshopify.com', formShop: null, method: 'GET'}, - ])( - 'returns a redirect to /auth if the shop is valid: %s', - async ({urlShop, formShop, method}) => { + describe.each([ + {isEmbeddedApp: false, futureFlag: false, redirectToInstall: false}, + {isEmbeddedApp: true, futureFlag: false, redirectToInstall: false}, + {isEmbeddedApp: false, futureFlag: true, redirectToInstall: false}, + {isEmbeddedApp: true, futureFlag: true, redirectToInstall: true}, + ])('Given setup: %s', (testCaseConfig) => { + it.each([ + {urlShop: null, formShop: TEST_SHOP, method: 'POST'}, + {urlShop: TEST_SHOP, formShop: null, method: 'GET'}, + {urlShop: null, formShop: 'test-shop', method: 'POST'}, + {urlShop: 'test-shop', formShop: null, method: 'GET'}, + {urlShop: null, formShop: 'test-shop.myshopify.com', method: 'POST'}, + {urlShop: 'test-shop.myshopify.com', formShop: null, method: 'GET'}, + ])( + 'returns a redirect to auth or install if the shop is valid: %s', + async ({urlShop, formShop, method}) => { + // GIVEN + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: testCaseConfig.futureFlag}, + isEmbeddedApp: testCaseConfig.isEmbeddedApp, + }); + const shopify = shopifyApp(config); + const requestMock = { + url: urlShop + ? `${APP_URL}/auth/login?shop=${urlShop}` + : `${APP_URL}/auth/login`, + formData: async () => ({get: () => formShop}), + method, + }; + + // WHEN + const response = await getThrownResponse( + shopify.login, + requestMock as any as Request, + ); + + // THEN + const expectedPath = testCaseConfig.redirectToInstall + ? `https://admin.shopify.com/store/${TEST_SHOP_NAME}/oauth/install?client_id=${config.apiKey}` + : `${APP_URL}/auth?shop=${TEST_SHOP}`; + + expect(response.status).toEqual(302); + expect(response.headers.get('location')).toEqual(expectedPath); + }, + ); + + it('sanitizes the shop parameter', async () => { // GIVEN - const config = testConfig(); - const shopify = shopifyApp(testConfig()); + const config = testConfig({ + future: {unstable_newEmbeddedAuthStrategy: testCaseConfig.futureFlag}, + isEmbeddedApp: testCaseConfig.isEmbeddedApp, + }); + const shopify = shopifyApp(config); const requestMock = { - url: urlShop - ? `${APP_URL}/auth/login?shop=${urlShop}` - : `${APP_URL}/auth/login`, - formData: async () => ({get: () => formShop}), - method, + url: `${APP_URL}/auth/login`, + formData: async () => ({get: () => `https://${TEST_SHOP}/`}), + method: 'POST', }; // WHEN @@ -106,33 +145,13 @@ describe('login helper', () => { requestMock as any as Request, ); + const expectedPath = testCaseConfig.redirectToInstall + ? `https://admin.shopify.com/store/${TEST_SHOP_NAME}/oauth/install?client_id=${config.apiKey}` + : `${APP_URL}/auth?shop=${TEST_SHOP}`; + // THEN expect(response.status).toEqual(302); - expect(response.headers.get('location')).toEqual( - `${APP_URL}/auth?shop=${TEST_SHOP}`, - ); - }, - ); - - it('sanitizes the shop parameter', async () => { - // GIVEN - const shopify = shopifyApp(testConfig()); - const requestMock = { - url: `${APP_URL}/auth/login`, - formData: async () => ({get: () => `https://${TEST_SHOP}/`}), - method: 'POST', - }; - - // WHEN - const response = await getThrownResponse( - shopify.login, - requestMock as any as Request, - ); - - // THEN - expect(response.status).toEqual(302); - expect(response.headers.get('location')).toEqual( - `${APP_URL}/auth?shop=${TEST_SHOP}`, - ); + expect(response.headers.get('location')).toEqual(expectedPath); + }); }); }); diff --git a/packages/shopify-app-remix/src/server/authenticate/login/login.ts b/packages/shopify-app-remix/src/server/authenticate/login/login.ts index 900600a89f..0b1e1355e6 100644 --- a/packages/shopify-app-remix/src/server/authenticate/login/login.ts +++ b/packages/shopify-app-remix/src/server/authenticate/login/login.ts @@ -35,7 +35,14 @@ export function loginFactory(params: BasicParams) { return {shop: LoginErrorType.InvalidShop}; } - const redirectUrl = `${config.appUrl}${config.auth.path}?shop=${sanitizedShop}`; + const authPath = `${config.appUrl}${config.auth.path}?shop=${sanitizedShop}`; + + const storeName = sanitizedShop.split('.')[0]; + const installPath = `https://admin.shopify.com/store/${storeName}/oauth/install?client_id=${config.apiKey}`; + + const shouldInstall = + config.isEmbeddedApp && config.future.unstable_newEmbeddedAuthStrategy; + const redirectUrl = shouldInstall ? installPath : authPath; logger.info(`Redirecting login request to ${redirectUrl}`, { shop: sanitizedShop, diff --git a/packages/shopify-app-remix/src/server/authenticate/public/appProxy/__tests__/authenticate.test.ts b/packages/shopify-app-remix/src/server/authenticate/public/appProxy/__tests__/authenticate.test.ts index 81eb4bc146..9d9ff62644 100644 --- a/packages/shopify-app-remix/src/server/authenticate/public/appProxy/__tests__/authenticate.test.ts +++ b/packages/shopify-app-remix/src/server/authenticate/public/appProxy/__tests__/authenticate.test.ts @@ -215,10 +215,9 @@ describe('authenticating app proxy requests', () => { describe('Valid requests with a session return an admin API client', () => { expectAdminApiClient(async () => { const shopify = shopifyApp(testConfig()); - const expectedSession = await setUpValidSession( - shopify.sessionStorage, - false, - ); + const expectedSession = await setUpValidSession(shopify.sessionStorage, { + isOnline: false, + }); const {admin, session: actualSession} = await shopify.authenticate.public.appProxy(await getValidRequest()); @@ -234,10 +233,9 @@ describe('authenticating app proxy requests', () => { describe('Valid requests with a session return a Storefront API client', () => { expectStorefrontApiClient(async () => { const shopify = shopifyApp(testConfig()); - const expectedSession = await setUpValidSession( - shopify.sessionStorage, - false, - ); + const expectedSession = await setUpValidSession(shopify.sessionStorage, { + isOnline: false, + }); const {storefront, session: actualSession} = await shopify.authenticate.public.appProxy(await getValidRequest()); diff --git a/packages/shopify-app-remix/src/server/config-types.ts b/packages/shopify-app-remix/src/server/config-types.ts index 78e9b6a3b7..4f68ce8df6 100644 --- a/packages/shopify-app-remix/src/server/config-types.ts +++ b/packages/shopify-app-remix/src/server/config-types.ts @@ -11,6 +11,7 @@ import {SessionStorage} from '@shopify/shopify-app-session-storage'; import type {FutureFlagOptions, FutureFlags} from './future/flags'; import type {AppDistribution} from './types'; import type {AdminApiContext} from './clients'; +import {IdempotentPromiseHandler} from './authenticate/helpers/idempotent-promise-handler'; export interface AppConfigArg< Resources extends ShopifyRestResources = ShopifyRestResources, @@ -233,6 +234,7 @@ export interface AppConfig useOnlineTokens: boolean; hooks: HooksConfig; future: FutureFlags; + idempotentPromiseHandler: IdempotentPromiseHandler; } export interface AuthConfig { diff --git a/packages/shopify-app-remix/src/server/future/flags.ts b/packages/shopify-app-remix/src/server/future/flags.ts index 8b8f1ccfd7..e8d75841ea 100644 --- a/packages/shopify-app-remix/src/server/future/flags.ts +++ b/packages/shopify-app-remix/src/server/future/flags.ts @@ -14,6 +14,13 @@ export interface FutureFlags { * @default false */ v3_authenticatePublic?: boolean; + + /** + * When enabled, embedded apps will fetch access tokens via token exchange. This assumes app are using declarative scopes with Shopify managing installs. + * + * @default false + */ + unstable_newEmbeddedAuthStrategy?: boolean; } export type FutureFlagOptions = FutureFlags | undefined; diff --git a/packages/shopify-app-remix/src/server/shopify-app.ts b/packages/shopify-app-remix/src/server/shopify-app.ts index 4b55649a9b..b3f5ac187a 100644 --- a/packages/shopify-app-remix/src/server/shopify-app.ts +++ b/packages/shopify-app-remix/src/server/shopify-app.ts @@ -30,6 +30,8 @@ import {unauthenticatedAdminContextFactory} from './unauthenticated/admin'; import {authenticatePublicFactory} from './authenticate/public'; import {unauthenticatedStorefrontContextFactory} from './unauthenticated/storefront'; import {AuthCodeFlowStrategy} from './authenticate/admin/strategies/auth-code-flow'; +import {TokenExchangeStrategy} from './authenticate/admin/strategies/token-exchange'; +import {IdempotentPromiseHandler} from './authenticate/helpers/idempotent-promise-handler'; /** * Creates an object your app will use to interact with Shopify. @@ -66,10 +68,12 @@ export function shopifyApp< } const params: BasicParams = {api, config, logger}; - const oauth = new AuthCodeFlowStrategy(params); const authStrategy = authStrategyFactory({ ...params, - strategy: oauth, + strategy: + config.future.unstable_newEmbeddedAuthStrategy && config.isEmbeddedApp + ? new TokenExchangeStrategy(params) + : new AuthCodeFlowStrategy(params), }); const shopify: @@ -148,7 +152,10 @@ function deriveApi(appConfig: AppConfigArg) { isEmbeddedApp: appConfig.isEmbeddedApp ?? true, apiVersion: appConfig.apiVersion ?? LATEST_API_VERSION, isCustomStoreApp: appConfig.distribution === AppDistribution.ShopifyAdmin, - future: {}, + future: { + unstable_tokenExchange: + appConfig.future?.unstable_newEmbeddedAuthStrategy, + }, }); } @@ -168,6 +175,7 @@ function deriveConfig( return { ...appConfig, ...apiConfig, + idempotentPromiseHandler: new IdempotentPromiseHandler(), canUseLoginForm: appConfig.distribution !== AppDistribution.ShopifyAdmin, useOnlineTokens: appConfig.useOnlineTokens ?? false, hooks: appConfig.hooks ?? {}, diff --git a/packages/shopify-app-remix/src/server/types.ts b/packages/shopify-app-remix/src/server/types.ts index a219a8ee5a..9d942981f5 100644 --- a/packages/shopify-app-remix/src/server/types.ts +++ b/packages/shopify-app-remix/src/server/types.ts @@ -1,4 +1,5 @@ import { + ConfigParams, RegisterReturn, Shopify, ShopifyRestResources, @@ -13,13 +14,29 @@ import type { import type {AuthenticatePublic} from './authenticate/public/types'; import type {AdminContext} from './authenticate/admin/types'; import type {Unauthenticated} from './unauthenticated/types'; +import {FutureFlagOptions, FutureFlags} from './future/flags'; -export interface BasicParams { - api: Shopify; +export interface BasicParams< + Future extends FutureFlagOptions = FutureFlagOptions, +> { + api: Shopify< + ApiConfigWithFutureFlags, + ShopifyRestResources, + ApiFutureFlags + >; config: AppConfig; logger: Shopify['logger']; } +export interface ApiFutureFlags { + unstable_tokenExchange?: Future extends FutureFlags + ? Future['unstable_newEmbeddedAuthStrategy'] + : boolean; +} + +export type ApiConfigWithFutureFlags = + ConfigParams>; + export type JSONValue = | string | number diff --git a/packages/shopify-app-remix/src/server/unauthenticated/admin/__tests__/factory.test.ts b/packages/shopify-app-remix/src/server/unauthenticated/admin/__tests__/factory.test.ts index 7e74ec6280..c7cbe53ef0 100644 --- a/packages/shopify-app-remix/src/server/unauthenticated/admin/__tests__/factory.test.ts +++ b/packages/shopify-app-remix/src/server/unauthenticated/admin/__tests__/factory.test.ts @@ -17,10 +17,9 @@ describe('unauthenticated admin context', () => { expectAdminApiClient(async () => { const shopify = shopifyApp(testConfig()); - const expectedSession = await setUpValidSession( - shopify.sessionStorage, - false, - ); + const expectedSession = await setUpValidSession(shopify.sessionStorage, { + isOnline: false, + }); const {admin, session: actualSession} = await shopify.unauthenticated.admin( TEST_SHOP, ); diff --git a/packages/shopify-app-remix/src/server/unauthenticated/storefront/__tests__/factory.test.ts b/packages/shopify-app-remix/src/server/unauthenticated/storefront/__tests__/factory.test.ts index 5ac01e34e6..c747ab4d25 100644 --- a/packages/shopify-app-remix/src/server/unauthenticated/storefront/__tests__/factory.test.ts +++ b/packages/shopify-app-remix/src/server/unauthenticated/storefront/__tests__/factory.test.ts @@ -19,10 +19,9 @@ describe('unauthenticated storefront context', () => { expectStorefrontApiClient(async () => { const shopify = shopifyApp(testConfig()); - const expectedSession = await setUpValidSession( - shopify.sessionStorage, - false, - ); + const expectedSession = await setUpValidSession(shopify.sessionStorage, { + isOnline: false, + }); const {storefront, session: actualSession} = await shopify.unauthenticated.storefront(TEST_SHOP);