From 69774780985dedf29e035b98be4003366bac35d5 Mon Sep 17 00:00:00 2001 From: River Date: Fri, 2 Oct 2026 16:48:45 +0000 Subject: [PATCH] Authenticate store and organization commands with an automation token ensureAuthenticatedBusinessPlatform now takes an allowAutomationToken option. When a caller opts in and SHOPIFY_APP_AUTOMATION_TOKEN or SHOPIFY_CLI_PARTNERS_TOKEN is set, it exchanges that token for a Business Platform token instead of using the logged-in session. The store and organizations packages opt in at every call site, including the 401 retry handler. Other callers, such as Hydrogen's login, keep the user's session. The opted-in exchange requests no explicit scopes, so Identity issues every Business Platform scope the token holds. Business Platform's organizations API admits a CLI token only with a scope such as organization.store-management, and requesting that scope by name would fail for app and Partner tokens, which don't hold it. App commands keep requesting destinations.readonly as before. Co-authored-by: Eddie Gao --- .../src/private/node/session/exchange.test.ts | 19 ++++++++ .../src/private/node/session/exchange.ts | 4 +- .../cli-kit/src/public/node/session.test.ts | 47 +++++++++++++++++++ packages/cli-kit/src/public/node/session.ts | 28 +++++++++-- .../src/cli/services/business-platform.ts | 4 +- .../cli/services/dev/create-dev-store.test.ts | 1 + .../src/cli/services/dev/create-dev-store.ts | 2 +- .../src/cli/services/fetch.test.ts | 1 + .../organizations/src/cli/services/fetch.ts | 4 +- .../src/cli/services/store/delete/dev.test.ts | 2 +- .../src/cli/services/store/delete/dev.ts | 2 +- .../store/src/cli/services/store/list.test.ts | 1 + packages/store/src/cli/services/store/list.ts | 2 +- .../cli/services/store/list/bp-source.test.ts | 5 +- .../store-lookup/destinations.test.ts | 2 +- .../utilities/store-lookup/destinations.ts | 4 +- .../store-lookup/organization-shop.test.ts | 2 +- .../store-lookup/organization-shop.ts | 4 +- 18 files changed, 118 insertions(+), 16 deletions(-) diff --git a/packages/cli-kit/src/private/node/session/exchange.test.ts b/packages/cli-kit/src/private/node/session/exchange.test.ts index 8c1b576b36f..3114970836b 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -474,3 +474,22 @@ describe('exchange device code for access token', () => { expect(result).toEqual(err('unknown_failure')) }) }) + +describe('exchangeAppAutomationTokenForBusinessPlatformAccessToken', () => { + test('requests no scopes when called with an empty scope list', async () => { + // Given + let capturedBody = '' + vi.mocked(shopifyFetch).mockImplementation(async (_url, options) => { + capturedBody = String((options as {body?: string} | undefined)?.body ?? '') + return Promise.resolve( + new Response(JSON.stringify({access_token: 'expected_access_token', expires_in: 300, scope: 'scope'})), + ) + }) + + // When + await exchangeAppAutomationTokenForBusinessPlatformAccessToken('customToken', []) + + // Then + expect(new URLSearchParams(capturedBody).get('scope')).toBe('') + }) +}) diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 91c18cf37cb..a0c75fd1ffa 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -126,12 +126,14 @@ export async function exchangeAppAutomationTokenForAppManagementAccessToken( /** * Given a custom app automation token passed as ENV variable, request a valid Business Platform API token. * @param token - The app automation token passed as ENV variable `SHOPIFY_APP_AUTOMATION_TOKEN` + * @param scopes - The scopes to request. An empty list makes Identity issue every Business Platform scope the token holds. * @returns An instance with the application access tokens. */ export async function exchangeAppAutomationTokenForBusinessPlatformAccessToken( token: string, + scopes: string[] = tokenExchangeScopes('business-platform'), ): Promise<{accessToken: string; userId: string}> { - return exchangeAppAutomationTokenForAccessToken('business-platform', token, tokenExchangeScopes('business-platform')) + return exchangeAppAutomationTokenForAccessToken('business-platform', token, scopes) } const identityDeviceErrors = [ diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index 449992bb4b8..75578e298ea 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -308,6 +308,51 @@ describe('ensureAuthenticatedBusinessPlatform', () => { // Then await expect(got).rejects.toThrow(`No business-platform token`) }) + + test('exchanges the automation token with no fixed scopes when the caller allows one', async () => { + // Given + vi.mocked(getAppAutomationToken).mockReturnValue('custom_cli_token') + vi.mocked(exchangeAppAutomationTokenForBusinessPlatformAccessToken).mockResolvedValueOnce({ + accessToken: 'business-platform-token', + userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', + }) + + // When + const got = await ensureAuthenticatedBusinessPlatform([], {noPrompt: true, allowAutomationToken: true}) + + // Then + expect(got).toEqual('business-platform-token') + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).toHaveBeenCalledWith('custom_cli_token', []) + expect(exchangeAppAutomationTokenForAppManagementAccessToken).not.toHaveBeenCalled() + expect(ensureAuthenticated).not.toHaveBeenCalled() + }) + + test('uses the logged-in session when the caller allows a token but none is set', async () => { + // Given + vi.mocked(getAppAutomationToken).mockReturnValue(undefined) + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({businessPlatform: 'business_platform', userId: '1234-5678'}) + + // When + const got = await ensureAuthenticatedBusinessPlatform([], {noPrompt: true, allowAutomationToken: true}) + + // Then + expect(got).toEqual('business_platform') + expect(ensureAuthenticated).toHaveBeenCalledWith({businessPlatformApi: {scopes: []}}, process.env, {noPrompt: true}) + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).not.toHaveBeenCalled() + }) + + test('ignores the automation token when the caller does not allow one', async () => { + // Given + vi.mocked(getAppAutomationToken).mockReturnValue('custom_cli_token') + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({businessPlatform: 'business_platform', userId: '1234-5678'}) + + // When + const got = await ensureAuthenticatedBusinessPlatform() + + // Then + expect(got).toEqual('business_platform') + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).not.toHaveBeenCalled() + }) }) describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { @@ -362,6 +407,8 @@ describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', businessPlatformToken: 'business-platform-token', }) + // App commands keep the exchange's default Business Platform scopes. + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).toHaveBeenCalledWith('custom_cli_token') expect(ensureAuthenticated).not.toHaveBeenCalled() }) }) diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 15be5d39cbf..93b7984b5d6 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -298,21 +298,43 @@ ${outputToken.json(scopes)} return ensureAuthenticatedAdmin(store, scopes, options) } +/** + * Options for `ensureAuthenticatedBusinessPlatform`. + */ +export interface EnsureAuthenticatedBusinessPlatformOptions extends EnsureAuthenticatedAdditionalOptions { + /** + * Authenticate with the automation token set in the environment, when there is one, instead of the + * logged-in user. Only commands that support automation tokens opt in; other callers, such as + * Hydrogen's login, keep using the user's session. + */ + allowAutomationToken?: boolean +} + /** * Ensure that we have a valid session to access the Business Platform API. * - * @param scopes - Optional array of extra scopes to authenticate with. + * @param scopes - Optional array of extra scopes to authenticate with. Ignored when an automation token is used. * @param options - Optional extra options to use. * @returns The access token for the Business Platform API. */ export async function ensureAuthenticatedBusinessPlatform( scopes: BusinessPlatformScope[] = [], - options: EnsureAuthenticatedAdditionalOptions = {}, + options: EnsureAuthenticatedBusinessPlatformOptions = {}, ): Promise { outputDebug(outputContent`Ensuring that the user is authenticated with the Business Platform API with the following scopes: ${outputToken.json(scopes)} `) - const tokens = await ensureAuthenticated({businessPlatformApi: {scopes}}, process.env, options) + const {allowAutomationToken = false, ...authenticationOptions} = options + const automationToken = allowAutomationToken ? getAppAutomationToken() : undefined + if (automationToken) { + // Request no explicit scopes, so Identity issues every Business Platform scope the token holds. The organizations + // API admits a CLI token only with a scope such as organization.store-management, and naming that scope would + // fail the exchange for app and Partner tokens, which don't hold it. + const result = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(automationToken, []) + return result.accessToken + } + + const tokens = await ensureAuthenticated({businessPlatformApi: {scopes}}, process.env, authenticationOptions) if (!tokens.businessPlatform) { throw new BugError('No business-platform token found after ensuring authenticated') } diff --git a/packages/organizations/src/cli/services/business-platform.ts b/packages/organizations/src/cli/services/business-platform.ts index 593a7396f85..d52cf98ef21 100644 --- a/packages/organizations/src/cli/services/business-platform.ts +++ b/packages/organizations/src/cli/services/business-platform.ts @@ -10,6 +10,8 @@ export function businessPlatformTokenRefreshHandler( ): UnauthorizedHandler { return { type: 'token_refresh', - handler: async () => ({token: await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})}), + handler: async () => ({ + token: await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt, allowAutomationToken: true}), + }), } } diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts index 23945f4d40c..eb32b2ab673 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts @@ -67,6 +67,7 @@ describe('createDevStore', () => { }) expect(domain).toBe('test-store.myshopify.com') + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {allowAutomationToken: true}) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenLastCalledWith( expect.objectContaining({ diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.ts b/packages/organizations/src/cli/services/dev/create-dev-store.ts index 192586a9c43..db9d5eaa238 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.ts @@ -57,7 +57,7 @@ function friendlyStatus(status: StoreCreationStatus): string { export async function createDevStore(options: CreateDevStoreOptions): Promise { const {organization: org, name, plan} = options - const token = await ensureAuthenticatedBusinessPlatform() + const token = await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true}) const unauthorizedHandler = businessPlatformTokenRefreshHandler() const mutationResult = await businessPlatformOrganizationsRequestDoc({ diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts index 6808b77eec2..b8713860a9f 100644 --- a/packages/organizations/src/cli/services/fetch.test.ts +++ b/packages/organizations/src/cli/services/fetch.test.ts @@ -98,6 +98,7 @@ describe('fetchOrganizations', () => { await fetchOrganizations() + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {allowAutomationToken: true}) expect(businessPlatformRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ token: 'test-token', diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts index 5b124fb54b7..45399529b68 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -48,7 +48,7 @@ export async function fetchOrganizationById( token?: string, unauthorizedHandler?: UnauthorizedHandler, ): Promise { - const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform()) + const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true})) const result = await businessPlatformRequestDoc({ query: FindOrganization, @@ -77,7 +77,7 @@ function encodedOrganizationGid(organizationId: string): string { } async function fetchOrganizationsWithDetails(token?: string): Promise { - const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform()) + const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true})) const result = await businessPlatformRequestDoc({ query: ListOrganizations, diff --git a/packages/store/src/cli/services/store/delete/dev.test.ts b/packages/store/src/cli/services/store/delete/dev.test.ts index e6fc29380d2..20eeacbbdc6 100644 --- a/packages/store/src/cli/services/store/delete/dev.test.ts +++ b/packages/store/src/cli/services/store/delete/dev.test.ts @@ -110,7 +110,7 @@ describe('deleteDevStore', () => { await deleteDevStore(defaultOptions) - expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalled() + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {allowAutomationToken: true}) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ query: expect.anything(), diff --git a/packages/store/src/cli/services/store/delete/dev.ts b/packages/store/src/cli/services/store/delete/dev.ts index 6ee07847b33..bc23bc90c03 100644 --- a/packages/store/src/cli/services/store/delete/dev.ts +++ b/packages/store/src/cli/services/store/delete/dev.ts @@ -35,7 +35,7 @@ interface StoreDeletionConfirmationOptions { export async function deleteDevStore(options: DeleteDevStoreOptions): Promise { const {organization: org, store} = options - const token = await ensureAuthenticatedBusinessPlatform() + const token = await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true}) const unauthorizedHandler = businessPlatformTokenRefreshHandler() let shopifyShopId = await fetchShopifyShopId({store, organizationId: org.id, token}) diff --git a/packages/store/src/cli/services/store/list.test.ts b/packages/store/src/cli/services/store/list.test.ts index ef14208fdfb..3e7647c41b9 100644 --- a/packages/store/src/cli/services/store/list.test.ts +++ b/packages/store/src/cli/services/store/list.test.ts @@ -48,6 +48,7 @@ describe('listStores', () => { const result = await listStores() + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {allowAutomationToken: true}) expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({ token: 'bp-token', organization: acme, diff --git a/packages/store/src/cli/services/store/list.ts b/packages/store/src/cli/services/store/list.ts index b8d63a912d3..2250c1970af 100644 --- a/packages/store/src/cli/services/store/list.ts +++ b/packages/store/src/cli/services/store/list.ts @@ -13,7 +13,7 @@ interface ListStoresOptions { } export async function listStores(options: ListStoresOptions = {}): Promise { - const token = await ensureAuthenticatedBusinessPlatform() + const token = await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true}) // Look the organization up directly instead of paging through every one the account belongs to. // A miss falls through to the list below, which supplies the accessible organizations for the diff --git a/packages/store/src/cli/services/store/list/bp-source.test.ts b/packages/store/src/cli/services/store/list/bp-source.test.ts index 9d2749557ce..66dde45dae9 100644 --- a/packages/store/src/cli/services/store/list/bp-source.test.ts +++ b/packages/store/src/cli/services/store/list/bp-source.test.ts @@ -269,6 +269,9 @@ describe('listBusinessPlatformStores', () => { const requestOptions = latestBusinessPlatformRequestOptions() await requestOptions.unauthorizedHandler.handler() - expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: undefined}) + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], { + noPrompt: undefined, + allowAutomationToken: true, + }) }) }) diff --git a/packages/store/src/cli/utilities/store-lookup/destinations.test.ts b/packages/store/src/cli/utilities/store-lookup/destinations.test.ts index 7531ca8e218..1f96c724266 100644 --- a/packages/store/src/cli/utilities/store-lookup/destinations.test.ts +++ b/packages/store/src/cli/utilities/store-lookup/destinations.test.ts @@ -157,6 +157,6 @@ describe('fetchDestinationsContext', () => { await fetchDestinationsContext({store: SHOP, noPrompt: true}) - expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: true}) + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: true, allowAutomationToken: true}) }) }) diff --git a/packages/store/src/cli/utilities/store-lookup/destinations.ts b/packages/store/src/cli/utilities/store-lookup/destinations.ts index 87731b2af9e..a9b97dd8bf4 100644 --- a/packages/store/src/cli/utilities/store-lookup/destinations.ts +++ b/packages/store/src/cli/utilities/store-lookup/destinations.ts @@ -31,7 +31,9 @@ interface FetchDestinationsContextOptions { export class StoreLookupStoreNotFoundError extends AbortError {} export async function fetchDestinationsContext(options: FetchDestinationsContextOptions): Promise { - const token = options.token ?? (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})) + const token = + options.token ?? + (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt, allowAutomationToken: true})) const unauthorizedHandler = businessPlatformTokenRefreshHandler({noPrompt: options.noPrompt}) // `options.store` is already a normalized FQDN; extractHost canonicalizes it (lowercased, diff --git a/packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts b/packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts index 401d350ac8c..3d03fbd51cc 100644 --- a/packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts +++ b/packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts @@ -81,7 +81,7 @@ describe('fetchOrganizationShop', () => { await fetchOrganizationShop({store: SHOP, organizationId: ORG_ID, noPrompt: true}) - expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: true}) + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: true, allowAutomationToken: true}) }) test('throws when organization is missing', async () => { diff --git a/packages/store/src/cli/utilities/store-lookup/organization-shop.ts b/packages/store/src/cli/utilities/store-lookup/organization-shop.ts index 5e84c75b7b1..6bbc281feac 100644 --- a/packages/store/src/cli/utilities/store-lookup/organization-shop.ts +++ b/packages/store/src/cli/utilities/store-lookup/organization-shop.ts @@ -33,7 +33,9 @@ export async function fetchOrganizationShop(options: FetchOrganizationShopOption export async function fetchOptionalOrganizationShop( options: FetchOrganizationShopOptions, ): Promise { - const token = options.token ?? (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})) + const token = + options.token ?? + (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt, allowAutomationToken: true})) const unauthorizedHandler = businessPlatformTokenRefreshHandler({noPrompt: options.noPrompt}) const response = await businessPlatformOrganizationsRequestDoc({