From 192cc6bb102fcee03fd32ba3cbffbf8838f8bdc2 Mon Sep 17 00:00:00 2001 From: Alvaro Gutierrez Date: Wed, 17 Jul 2024 10:53:16 +0200 Subject: [PATCH] Added query operation to the scopes api --- .changeset/four-coats-check.md | 5 + .changeset/real-emus-walk.md | 5 + packages/apps/shopify-api/lib/auth/types.ts | 1 + .../server/authenticate/admin/authenticate.ts | 2 +- .../admin/scope/__tests__/mock-responses.ts | 24 ++++ .../admin/scope/__tests__/query.test.ts | 133 ++++++++++++++++++ .../scope/client/fetch-scopes-information.ts | 40 ++++++ .../authenticate/admin/scope/factory.ts | 4 + .../server/authenticate/admin/scope/query.ts | 54 +++++++ .../server/authenticate/admin/scope/types.ts | 15 ++ 10 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 .changeset/four-coats-check.md create mode 100644 .changeset/real-emus-walk.md create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/mock-responses.ts create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/query.test.ts create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/client/fetch-scopes-information.ts create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/query.ts diff --git a/.changeset/four-coats-check.md b/.changeset/four-coats-check.md new file mode 100644 index 000000000..74f24093a --- /dev/null +++ b/.changeset/four-coats-check.md @@ -0,0 +1,5 @@ +--- +'@shopify/shopify-app-remix': minor +--- + +Added query scopes information api diff --git a/.changeset/real-emus-walk.md b/.changeset/real-emus-walk.md new file mode 100644 index 000000000..571bf2bd2 --- /dev/null +++ b/.changeset/real-emus-walk.md @@ -0,0 +1,5 @@ +--- +'@shopify/shopify-api': patch +--- + +Expose AuthScopes type diff --git a/packages/apps/shopify-api/lib/auth/types.ts b/packages/apps/shopify-api/lib/auth/types.ts index 4a372d1d0..22f983d34 100644 --- a/packages/apps/shopify-api/lib/auth/types.ts +++ b/packages/apps/shopify-api/lib/auth/types.ts @@ -1,6 +1,7 @@ import {AdapterArgs} from '../../runtime/http'; export * from './oauth/types'; +export * from './scopes/index'; export {RequestedTokenType} from './oauth/token-exchange'; export interface GetEmbeddedAppUrlParams extends AdapterArgs {} diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts index 029dc8078..908aae73b 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts @@ -127,7 +127,7 @@ export function authStrategyFactory< if (config.future.unstable_optionalScopesApi) { return { ...context, - scopes: scopesApiFactory(params, context.session), + scopes: scopesApiFactory(params, context.session, context.admin), }; } return context; diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/mock-responses.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/mock-responses.ts new file mode 100644 index 000000000..fc5380325 --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/mock-responses.ts @@ -0,0 +1,24 @@ +export const WITH_GRANTED_AND_DECLARED = JSON.stringify({ + data: { + app: { + requestedAccessScopes: [ + { + handle: 'write_orders', + }, + { + handle: 'read_reports', + }, + ], + installation: { + accessScopes: [ + { + handle: 'read_orders', + }, + { + handle: 'write_customers', + }, + ], + }, + }, + }, +}); diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/query.test.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/query.test.ts new file mode 100644 index 000000000..2072f791a --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/__tests__/query.test.ts @@ -0,0 +1,133 @@ +import { + APP_URL, + BASE64_HOST, + TEST_SHOP, + expectExitIframeRedirect, + getJwt, + getThrownResponse, + mockExternalRequest, + setUpValidSession, + testConfig, +} from '../../../../__test-helpers'; +import {LATEST_API_VERSION, shopifyApp} from '../../../..'; +import {REAUTH_URL_HEADER} from '../../../const'; + +import * as responses from './mock-responses'; + +it('returns scopes information', async () => { + // GIVEN + const {scopes} = await setUpEmbeddedFlow(); + await mockGraphqlRequest(200, responses.WITH_GRANTED_AND_DECLARED); + + // WHEN + const result = await scopes.query(); + + // THEN + expect(result).not.toBeUndefined(); + expect(result.granted.required).toEqual(['read_orders']); + expect(result.granted.optional).toEqual(['write_customers']); + expect(result.declared.required).toEqual(['write_orders', 'read_reports']); +}); + +it('redirects to exit-iframe with authentication using app bridge when embedded and Shopify invalidated the session', async () => { + // GIVEN + const {scopes} = await setUpEmbeddedFlow(); + const requestMock = await mockGraphqlRequest(401); + + // WHEN + const response = await getThrownResponse( + async () => scopes.query(), + requestMock, + ); + + // THEN + expectExitIframeRedirect(response); +}); + +it('returns app bridge redirection during request headers when Shopify invalidated the session', async () => { + // GIVEN + const {scopes} = await setUpFetchFlow(); + const requestMock = await mockGraphqlRequest(401); + + // WHEN + const response = await getThrownResponse( + async () => scopes.query(), + 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); +}); + +it('return an unexpected error when there is no authentication error', async () => { + // GIVEN + const {scopes} = await setUpFetchFlow(); + await mockGraphqlRequest(500); + + // WHEN / THEN + try { + await scopes.query(); + } catch (error) { + expect(error.status).toEqual(500); + } +}); + +async function setUpEmbeddedFlow() { + const shopify = shopifyApp( + testConfig({ + future: {unstable_newEmbeddedAuthStrategy: false}, + }), + ); + 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}, + }), + ); + 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 mockGraphqlRequest(status = 401, responseContent?: string) { + const requestMock = new Request( + `https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/graphql.json`, + {method: 'POST'}, + ); + + await mockExternalRequest({ + request: requestMock, + response: new Response(responseContent, {status}), + }); + + return requestMock; +} diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/client/fetch-scopes-information.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/client/fetch-scopes-information.ts new file mode 100644 index 000000000..219f94891 --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/client/fetch-scopes-information.ts @@ -0,0 +1,40 @@ +import {AdminApiContext} from '../../../../clients'; + +export interface FetchScopeInformationResponse { + app: { + requestedAccessScopes: { + handle: string; + }[]; + optionalAccessScopes: { + handle: string; + }[]; + installation: { + accessScopes: { + handle: string; + }[]; + }; + }; +} + +const FETCH_SCOPE_INFORMATION_QUERY = `#graphql +query FetchAccessScopes{ + app { + requestedAccessScopes { + handle + } + installation { + accessScopes { + handle + } + } + } +}`; + +export async function fetchScopeInformation(admin: AdminApiContext) { + const fetchScopeInformationResult = await admin.graphql( + FETCH_SCOPE_INFORMATION_QUERY, + ); + + return (await fetchScopeInformationResult.json()) + .data as FetchScopeInformationResponse; +} diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/factory.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/factory.ts index b4e495e4e..c4258a575 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/factory.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/factory.ts @@ -1,15 +1,19 @@ import {Session} from '@shopify/shopify-api'; import {BasicParams} from '../../../types'; +import {AdminApiContext} from '../../../clients'; import {ScopesApiContext} from './types'; import {requestScopesFactory} from './request'; +import {queryScopesFactory} from './query'; export function scopesApiFactory( params: BasicParams, session: Session, + admin: AdminApiContext, ): ScopesApiContext { return { + query: queryScopesFactory(params, session, admin), request: requestScopesFactory(params, session), }; } diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/query.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/query.ts new file mode 100644 index 000000000..d216c8f2d --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/query.ts @@ -0,0 +1,54 @@ +import {AuthScopes, Session} from '@shopify/shopify-api'; + +import {AdminApiContext} from '../../../clients'; +import type {BasicParams} from '../../../types'; + +import {ScopesInformation} from './types'; +import { + FetchScopeInformationResponse, + fetchScopeInformation, +} from './client/fetch-scopes-information'; + +export function queryScopesFactory( + params: BasicParams, + session: Session, + admin: AdminApiContext, +) { + return async function queryScopes() { + const {logger} = params; + + logger.debug('Query scopes information: ', { + shop: session.shop, + }); + + const scopesInformation = await fetchScopeInformation(admin); + return mapFetchScopeInformation(scopesInformation); + }; +} + +function mapFetchScopeInformation( + fetchScopeInformation: FetchScopeInformationResponse, +): ScopesInformation { + const appInformation = fetchScopeInformation.app; + const declaredRequired = new AuthScopes( + appInformation.requestedAccessScopes.map((scope) => scope.handle), + ); + + const grantedRequired = appInformation.installation.accessScopes + .map((scope) => scope.handle) + .filter((scope) => declaredRequired.has(scope)); + + const grantedOptional = appInformation.installation.accessScopes + .map((scope) => scope.handle) + .filter((scope) => !declaredRequired.has(scope)); + + return { + granted: { + required: grantedRequired, + optional: grantedOptional, + }, + declared: { + required: declaredRequired.toArray(), + }, + }; +} diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/types.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/types.ts index 0fa8d12dd..9d428ff1d 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/types.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/scope/types.ts @@ -1,3 +1,18 @@ export interface ScopesApiContext { + query: () => Promise; request: (scopes: string[]) => Promise; } + +export interface ScopesInformation { + granted: GrantedScopes; + declared: DeclaredScopes; +} + +export interface DeclaredScopes { + required: string[]; +} + +export interface GrantedScopes { + required: string[]; + optional: string[]; +}