Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/cli-kit/src/private/node/session/exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
})
})
4 changes: 3 additions & 1 deletion packages/cli-kit/src/private/node/session/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
47 changes: 47 additions & 0 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
Expand Down
28 changes: 25 additions & 3 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
}),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function friendlyStatus(status: StoreCreationStatus): string {

export async function createDevStore(options: CreateDevStoreOptions): Promise<string> {
const {organization: org, name, plan} = options
const token = await ensureAuthenticatedBusinessPlatform()
const token = await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true})
const unauthorizedHandler = businessPlatformTokenRefreshHandler()

const mutationResult = await businessPlatformOrganizationsRequestDoc({
Expand Down
1 change: 1 addition & 0 deletions packages/organizations/src/cli/services/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe('fetchOrganizations', () => {

await fetchOrganizations()

expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {allowAutomationToken: true})
expect(businessPlatformRequestDoc).toHaveBeenCalledWith(
expect.objectContaining({
token: 'test-token',
Expand Down
4 changes: 2 additions & 2 deletions packages/organizations/src/cli/services/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function fetchOrganizationById(
token?: string,
unauthorizedHandler?: UnauthorizedHandler,
): Promise<Organization | undefined> {
const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform())
const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true}))

const result = await businessPlatformRequestDoc({
query: FindOrganization,
Expand Down Expand Up @@ -77,7 +77,7 @@ function encodedOrganizationGid(organizationId: string): string {
}

async function fetchOrganizationsWithDetails(token?: string): Promise<FetchOrganizationsWithDetailsResult> {
const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform())
const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform([], {allowAutomationToken: true}))

const result = await businessPlatformRequestDoc({
query: ListOrganizations,
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/cli/services/store/delete/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/cli/services/store/delete/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface StoreDeletionConfirmationOptions {

export async function deleteDevStore(options: DeleteDevStoreOptions): Promise<void> {
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})

Expand Down
1 change: 1 addition & 0 deletions packages/store/src/cli/services/store/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('listStores', () => {

const result = await listStores()

expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {allowAutomationToken: true})
expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({
token: 'bp-token',
organization: acme,
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/cli/services/store/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface ListStoresOptions {
}

export async function listStores(options: ListStoresOptions = {}): Promise<StoreListResult> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,6 @@ describe('fetchDestinationsContext', () => {

await fetchDestinationsContext({store: SHOP, noPrompt: true})

expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: true})
expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: true, allowAutomationToken: true})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ interface FetchDestinationsContextOptions {
export class StoreLookupStoreNotFoundError extends AbortError {}

export async function fetchDestinationsContext(options: FetchDestinationsContextOptions): Promise<DestinationsContext> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export async function fetchOrganizationShop(options: FetchOrganizationShopOption
export async function fetchOptionalOrganizationShop(
options: FetchOrganizationShopOptions,
): Promise<OrganizationShopFields | undefined> {
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<StoreInfoShopQuery, StoreInfoShopQueryVariables>({
Expand Down
Loading