diff --git a/.changeset/business-platform-app-automation-token.md b/.changeset/business-platform-app-automation-token.md new file mode 100644 index 00000000000..a70c848e0d7 --- /dev/null +++ b/.changeset/business-platform-app-automation-token.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': minor +--- + +Authenticate Business Platform commands with `SHOPIFY_APP_AUTOMATION_TOKEN`, including organization-scoped tokens diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.session.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.session.test.ts new file mode 100644 index 00000000000..be87917f9f2 --- /dev/null +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.session.test.ts @@ -0,0 +1,70 @@ +import {AppManagementClient} from './app-management-client.js' +import {getAutomationToken} from '@shopify/cli-kit/node/environment' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {isUnitTest} from '@shopify/cli-kit/node/context/local' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/environment') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/context/local') +vi.mock('@shopify/cli-kit/node/api/business-platform') + +beforeEach(() => { + AppManagementClient.resetInstance() + vi.mocked(isUnitTest).mockReturnValue(false) + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ + appManagementToken: 'app-management-token', + businessPlatformToken: 'business-platform-token', + userId: 'automation-user-id', + }) +}) + +describe('AppManagementClient session account classification', () => { + test('classifies an organization automation token as a service account', async () => { + // Given + vi.mocked(getAutomationToken).mockReturnValue({ + value: 'organization-automation-token', + source: 'organization', + }) + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'automation-user-id', + email: 'automation@example.com', + organizations: {nodes: [{name: 'Automation Organization'}]}, + }, + }) + + // When + const session = await AppManagementClient.getInstance().session() + + // Then + expect(session.accountInfo).toEqual({ + type: 'ServiceAccount', + orgName: 'Automation Organization', + }) + }) + + test('propagates an organization automation token exchange failure during an unauthorized retry', async () => { + // Given + vi.mocked(getAutomationToken).mockReturnValue({ + value: 'organization-automation-token', + source: 'organization', + }) + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'automation-user-id', + email: 'automation@example.com', + organizations: {nodes: [{name: 'Automation Organization'}]}, + }, + }) + const client = AppManagementClient.getInstance() + await client.session() + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockRejectedValueOnce( + new Error('Token exchange failed'), + ) + + // When/Then + await expect(client.unsafeRefreshToken()).rejects.toThrow('Token exchange failed') + }) +}) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index c611a5dfa17..28a881dcddb 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -135,7 +135,7 @@ import { import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' import {WebhookSubscriptionSpecIdentifier} from '../../models/extensions/specifications/app_config_webhook_subscription.js' import {fetchOrganizations} from '@shopify/organizations' -import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' +import {getAutomationToken} from '@shopify/cli-kit/node/environment' import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session' import {isUnitTest} from '@shopify/cli-kit/node/context/local' import {AbortError, BugError} from '@shopify/cli-kit/node/error' @@ -285,7 +285,7 @@ export class AppManagementClient implements DeveloperPlatformClient { unauthorizedHandler: this.createUnauthorizedHandler('businessPlatform'), }) - if (getAppAutomationToken() && userInfoResult.currentUserAccount) { + if (getAutomationToken() && userInfoResult.currentUserAccount) { const organizations = userInfoResult.currentUserAccount.organizations.nodes.map((org) => ({ name: org.name, })) diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index f95c88295e0..322547a3c7b 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -21,6 +21,7 @@ export const environmentVariables = { env: 'SHOPIFY_CLI_ENV', noAnalytics: 'SHOPIFY_CLI_NO_ANALYTICS', optOutInstrumentation: 'OPT_OUT_INSTRUMENTATION', + organizationAutomationToken: 'SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN', appAutomationToken: 'SHOPIFY_APP_AUTOMATION_TOKEN', partnersToken: 'SHOPIFY_CLI_PARTNERS_TOKEN', runAsUser: 'SHOPIFY_RUN_AS_USER', diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index d781fff189a..ba4ea7ef2c7 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -25,7 +25,7 @@ import * as fqdnModule from '../../public/node/context/fqdn.js' import {themeToken} from '../../public/node/context/local.js' import {partnersRequest} from '../../public/node/api/partners.js' import {businessPlatformRequest} from '../../public/node/api/business-platform.js' -import {getAppAutomationToken} from '../../public/node/environment.js' +import {getAutomationToken} from '../../public/node/environment.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {terminalSupportsPrompting} from '../../public/node/system.js' @@ -298,6 +298,18 @@ The CLI is currently unable to prompt for reauthentication.`, }) describe('when existing session is valid', () => { + test('does not fall back to the cached human session for unsupported organization token commands', async () => { + vi.mocked(getAutomationToken).mockReturnValue({value: 'organization-token', source: 'organization'}) + vi.mocked(fetchSessions).mockResolvedValue(validSessions) + + await expect(ensureAuthenticated(defaultApplications)).rejects.toThrow( + "The organization automation token can't be used for this command.", + ) + + expect(fetchSessions).not.toHaveBeenCalled() + expect(validateSession).not.toHaveBeenCalled() + }) + test('does nothing', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('ok') @@ -347,7 +359,7 @@ describe('when existing session is valid', () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('ok') vi.mocked(fetchSessions).mockResolvedValue(validSessions) - vi.mocked(getAppAutomationToken).mockReturnValue('custom_cli_token') + vi.mocked(getAutomationToken).mockReturnValue({value: 'custom_cli_token', source: 'app'}) const expected = {...validTokens, partners: 'custom_partners_token'} // When @@ -505,7 +517,7 @@ describe('getLastSeenUserIdAfterAuth', () => { test('returns UUID based on partners token if present in environment', async () => { // Given vi.mocked(getCurrentSessionId).mockReturnValue(undefined) - vi.mocked(getAppAutomationToken).mockReturnValue('partners-token-456') + vi.mocked(getAutomationToken).mockReturnValue({value: 'partners-token-456', source: 'partners'}) // When const userId = await getLastSeenUserIdAfterAuth() @@ -595,7 +607,7 @@ describe('setLastSeenUserIdAfterAuth', () => { describe('getLastSeenAuthMethod', () => { beforeEach(() => { vi.mocked(getCurrentSessionId).mockReturnValue(undefined) - vi.mocked(getAppAutomationToken).mockReturnValue(undefined) + vi.mocked(getAutomationToken).mockReturnValue(undefined) vi.mocked(themeToken).mockReturnValue(undefined) setLastSeenAuthMethod('none') }) @@ -626,7 +638,7 @@ describe('getLastSeenAuthMethod', () => { test('returns partners_token if there is a partners token in the environment', async () => { // Given - vi.mocked(getAppAutomationToken).mockReturnValue('partners-token-456') + vi.mocked(getAutomationToken).mockReturnValue({value: 'partners-token-456', source: 'partners'}) // When const method = await getLastSeenAuthMethod() diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3eb9f9e5ee6..5e8722b748e 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -19,7 +19,7 @@ import {outputContent, outputToken, outputDebug, outputCompleted} from '../../pu import {themeToken} from '../../public/node/context/local.js' import {AbortError} from '../../public/node/error.js' import {normalizeStoreFqdn, identityFqdn} from '../../public/node/context/fqdn.js' -import {getIdentityTokenInformation, getAppAutomationToken} from '../../public/node/environment.js' +import {getIdentityTokenInformation, getAutomationToken} from '../../public/node/environment.js' import {AdminSession, logout} from '../../public/node/session.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {isEmpty} from '../../public/common/object.js' @@ -133,7 +133,7 @@ let commandSessionId: string | undefined * @returns A Promise that resolves to the user ID as a string. */ export async function getLastSeenUserIdAfterAuth(): Promise { - const customToken = getAppAutomationToken() ?? themeToken() + const customToken = getAutomationToken()?.value ?? themeToken() if (customToken) return nonRandomUUID(customToken) if (userId) return userId @@ -165,8 +165,8 @@ export async function getLastSeenAuthMethod(): Promise { if (getCurrentSessionId()) return 'device_auth' - const appAutomationToken = getAppAutomationToken() - if (appAutomationToken) return 'partners_token' + const automationToken = getAutomationToken() + if (automationToken) return 'partners_token' const themePassword = themeToken() if (themePassword) { @@ -200,9 +200,16 @@ export interface EnsureAuthenticatedAdditionalOptions { */ export async function ensureAuthenticated( applications: OAuthApplications, - _env?: NodeJS.ProcessEnv, + env = process.env, {forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {}, ): Promise { + const automationToken = getAutomationToken(env) + if (automationToken?.source === 'organization') { + throw new AbortError( + "The organization automation token can't be used for this command.", + 'Use a command that supports organization automation tokens or unset SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.', + ) + } const fqdn = await identityFqdn() const previousStoreFqdn = applications.adminApi?.storeFqdn @@ -270,12 +277,11 @@ ${outputToken.json(applications)} const tokens = await tokensFor(applications, completeSession) - const envToken = getAppAutomationToken() - if (envToken && applications.partnersApi) { - tokens.partners = (await exchangeCustomPartnerToken(envToken)).accessToken + if (automationToken && applications.partnersApi) { + tokens.partners = (await exchangeCustomPartnerToken(automationToken.value)).accessToken } - setLastSeenAuthMethod(envToken ? 'partners_token' : 'device_auth') + setLastSeenAuthMethod(automationToken ? 'partners_token' : 'device_auth') setLastSeenUserIdAfterAuth(tokens.userId) return tokens } 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..ec2ccda529e 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -259,7 +259,7 @@ const tokenExchangeMethods = [ }, { tokenExchangeMethod: exchangeAppAutomationTokenForBusinessPlatformAccessToken, - expectedScopes: ['https://api.shopify.com/auth/destinations.readonly'], + expectedScopes: [], expectedApi: 'business-platform', expectedErrorName: 'Business Platform', }, diff --git a/packages/cli-kit/src/private/node/session/scopes.test.ts b/packages/cli-kit/src/private/node/session/scopes.test.ts index 8a42421dda5..b43e59beca1 100644 --- a/packages/cli-kit/src/private/node/session/scopes.test.ts +++ b/packages/cli-kit/src/private/node/session/scopes.test.ts @@ -82,12 +82,12 @@ describe('tokenExchangeScopes', () => { expect(got).toEqual(['https://api.shopify.com/auth/organization.apps.manage']) }) - test('returns transformed scopes for business-platform API', () => { + test('returns no scopes for business-platform API, so Identity grants the token its own scopes', () => { // When const got = tokenExchangeScopes('business-platform') // Then - expect(got).toEqual(['https://api.shopify.com/auth/destinations.readonly']) + expect(got).toEqual([]) }) test('throws an error for unsupported APIs', () => { diff --git a/packages/cli-kit/src/private/node/session/scopes.ts b/packages/cli-kit/src/private/node/session/scopes.ts index 3a73b2d98c5..00be76d8a11 100644 --- a/packages/cli-kit/src/private/node/session/scopes.ts +++ b/packages/cli-kit/src/private/node/session/scopes.ts @@ -38,7 +38,10 @@ export function tokenExchangeScopes(api: API): string[] { case 'app-management': return [scopeTransform('app-management')] case 'business-platform': - return [scopeTransform('destinations')] + // Identity refuses to grant a scope the app automation token doesn't already hold, and an + // app-scoped token holds fewer scopes than an organization-scoped one. Asking for none lets + // Identity grant whatever the token carries, narrowed to Business Platform. + return [] case 'admin': case 'storefront-renderer': throw new BugError(`API not supported for token exchange: ${api}`) diff --git a/packages/cli-kit/src/public/node/environment.test.ts b/packages/cli-kit/src/public/node/environment.test.ts index d69dc6e4baa..c6181f530cf 100644 --- a/packages/cli-kit/src/public/node/environment.test.ts +++ b/packages/cli-kit/src/public/node/environment.test.ts @@ -1,36 +1,80 @@ -import {getAppAutomationToken, getBackendPort, maxRequestTimeForNetworkCallsMs} from './environment.js' +import { + getAppAutomationToken, + getAutomationToken, + getBackendPort, + maxRequestTimeForNetworkCallsMs, +} from './environment.js' import {environmentVariables, systemEnvironmentVariables} from '../../private/node/constants.js' import {describe, expect, test, beforeEach} from 'vitest' beforeEach(() => { + delete process.env[environmentVariables.organizationAutomationToken] delete process.env[environmentVariables.appAutomationToken] delete process.env[environmentVariables.partnersToken] delete process.env[systemEnvironmentVariables.backendPort] delete process.env[environmentVariables.maxRequestTimeForNetworkCalls] }) -describe('getAppAutomationToken', () => { - test('returns SHOPIFY_APP_AUTOMATION_TOKEN when set', () => { - process.env[environmentVariables.appAutomationToken] = 'new-token' +describe('getAutomationToken', () => { + test('returns SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN when set', () => { + process.env[environmentVariables.organizationAutomationToken] = 'organization-token' + + expect(getAutomationToken()).toEqual({value: 'organization-token', source: 'organization'}) + }) + + test('returns SHOPIFY_APP_AUTOMATION_TOKEN when no organization token is set', () => { + process.env[environmentVariables.appAutomationToken] = 'app-token' + + expect(getAutomationToken()).toEqual({value: 'app-token', source: 'app'}) + }) + + test('returns deprecated SHOPIFY_CLI_PARTNERS_TOKEN when no automation token is set', () => { + process.env[environmentVariables.partnersToken] = 'partners-token' + + expect(getAutomationToken()).toEqual({value: 'partners-token', source: 'partners'}) + }) - expect(getAppAutomationToken()).toBe('new-token') + test('prefers SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN over deprecated SHOPIFY_CLI_PARTNERS_TOKEN', () => { + process.env[environmentVariables.organizationAutomationToken] = 'organization-token' + process.env[environmentVariables.partnersToken] = 'partners-token' + + expect(getAutomationToken()).toEqual({value: 'organization-token', source: 'organization'}) }) - test('returns SHOPIFY_CLI_PARTNERS_TOKEN when SHOPIFY_APP_AUTOMATION_TOKEN is not set', () => { - process.env[environmentVariables.partnersToken] = 'old-token' + test('preserves SHOPIFY_APP_AUTOMATION_TOKEN precedence over deprecated SHOPIFY_CLI_PARTNERS_TOKEN', () => { + process.env[environmentVariables.appAutomationToken] = 'app-token' + process.env[environmentVariables.partnersToken] = 'partners-token' - expect(getAppAutomationToken()).toBe('old-token') + expect(getAutomationToken()).toEqual({value: 'app-token', source: 'app'}) }) - test('prefers SHOPIFY_APP_AUTOMATION_TOKEN over SHOPIFY_CLI_PARTNERS_TOKEN', () => { - process.env[environmentVariables.appAutomationToken] = 'new-token' - process.env[environmentVariables.partnersToken] = 'old-token' + test('rejects simultaneous non-empty organization and app automation tokens', () => { + process.env[environmentVariables.organizationAutomationToken] = 'organization-token' + process.env[environmentVariables.appAutomationToken] = 'app-token' - expect(getAppAutomationToken()).toBe('new-token') + expect(() => getAutomationToken()).toThrow( + "SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN and SHOPIFY_APP_AUTOMATION_TOKEN can't both be set.", + ) }) - test('returns undefined when neither env var is set', () => { - expect(getAppAutomationToken()).toBeUndefined() + test('ignores empty automation token values', () => { + process.env[environmentVariables.organizationAutomationToken] = '' + process.env[environmentVariables.appAutomationToken] = '' + process.env[environmentVariables.partnersToken] = 'partners-token' + + expect(getAutomationToken()).toEqual({value: 'partners-token', source: 'partners'}) + }) + + test('returns undefined when no token is set', () => { + expect(getAutomationToken()).toBeUndefined() + }) +}) + +describe('getAppAutomationToken', () => { + test('returns the canonical token value for backwards compatibility', () => { + process.env[environmentVariables.organizationAutomationToken] = 'organization-token' + + expect(getAppAutomationToken()).toBe('organization-token') }) }) diff --git a/packages/cli-kit/src/public/node/environment.ts b/packages/cli-kit/src/public/node/environment.ts index 981e936250e..06237416e7b 100644 --- a/packages/cli-kit/src/public/node/environment.ts +++ b/packages/cli-kit/src/public/node/environment.ts @@ -1,4 +1,5 @@ import {nonRandomUUID} from './crypto.js' +import {AbortError} from './error.js' import {isTruthy} from './context/utilities.js' import {sniffForJson} from './path.js' import {environmentVariables, systemEnvironmentVariables} from '../../private/node/constants.js' @@ -17,15 +18,53 @@ export function getEnvironmentVariables(): NodeJS.ProcessEnv { return process.env } +export interface AutomationToken { + value: string + source: 'organization' | 'app' | 'partners' +} + /** - * Returns the value of the SHOPIFY_APP_AUTOMATION_TOKEN environment variable, - * falling back to the deprecated SHOPIFY_CLI_PARTNERS_TOKEN. + * Selects the automation token to use for authentication. * - * @returns The app automation token value, or undefined if neither env var is set. + * Organization and app automation tokens are mutually exclusive because they represent different + * authentication subjects. The deprecated Partners token remains a fallback for compatibility. + * + * @param env - Environment variables to select the token from. + * @returns The selected automation token and its source, or undefined if none is set. + * @throws AbortError when both organization and app automation tokens are set. + */ +export function getAutomationToken(env = getEnvironmentVariables()): AutomationToken | undefined { + const organizationToken = nonEmptyEnvironmentVariable(env[environmentVariables.organizationAutomationToken]) + const appToken = nonEmptyEnvironmentVariable(env[environmentVariables.appAutomationToken]) + const partnersToken = nonEmptyEnvironmentVariable(env[environmentVariables.partnersToken]) + + if (organizationToken && appToken) { + throw new AbortError( + "SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN and SHOPIFY_APP_AUTOMATION_TOKEN can't both be set.", + 'Unset one of the automation token environment variables and try again.', + ) + } + + if (organizationToken) return {value: organizationToken, source: 'organization'} + if (appToken) return {value: appToken, source: 'app'} + if (partnersToken) return {value: partnersToken, source: 'partners'} + return undefined +} + +/** + * Returns the selected automation token value. + * + * Prefer getAutomationToken when the token source is needed. + * + * @returns The selected automation token value, or undefined if none is set. */ export function getAppAutomationToken(): string | undefined { - const env = getEnvironmentVariables() - return env[environmentVariables.appAutomationToken] ?? env[environmentVariables.partnersToken] + return getAutomationToken()?.value +} + +function nonEmptyEnvironmentVariable(value: string | undefined): string | undefined { + if (value === '') return undefined + return value } /** diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index 449992bb4b8..845c7acdd36 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -12,7 +12,7 @@ import { } from './session.js' import {nonRandomUUID} from './crypto.js' -import {getAppAutomationToken} from './environment.js' +import {getAutomationToken} from './environment.js' import {shopifyFetch} from './http.js' import { ensureAuthenticated, @@ -198,7 +198,7 @@ describe('ensureAuthenticatedPartners', () => { accessToken: partnersToken.accessToken, userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', }) - vi.mocked(getAppAutomationToken).mockReturnValue('custom_cli_token') + vi.mocked(getAutomationToken).mockReturnValue({value: 'custom_cli_token', source: 'app'}) // When const got = await ensureAuthenticatedPartners([]) @@ -308,6 +308,43 @@ describe('ensureAuthenticatedBusinessPlatform', () => { // Then await expect(got).rejects.toThrow(`No business-platform token`) }) + + test('exchanges the organization automation token for Business Platform access', async () => { + // Given + vi.mocked(exchangeAppAutomationTokenForBusinessPlatformAccessToken).mockResolvedValueOnce({ + accessToken: 'business_platform_token_from_env', + userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', + }) + vi.mocked(getAutomationToken).mockReturnValue({ + value: 'organization_automation_token', + source: 'organization', + }) + + // When + const got = await ensureAuthenticatedBusinessPlatform() + + // Then + expect(got).toEqual('business_platform_token_from_env') + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).toHaveBeenCalledWith( + 'organization_automation_token', + ) + expect(ensureAuthenticated).not.toHaveBeenCalled() + }) + + test('does not fall back to interactive authentication when automation token exchange fails', async () => { + // Given + vi.mocked(getAutomationToken).mockReturnValue({ + value: 'organization_automation_token', + source: 'organization', + }) + vi.mocked(exchangeAppAutomationTokenForBusinessPlatformAccessToken).mockRejectedValueOnce( + new Error('Token exchange failed'), + ) + + // When/Then + await expect(ensureAuthenticatedBusinessPlatform()).rejects.toThrow('Token exchange failed') + expect(ensureAuthenticated).not.toHaveBeenCalled() + }) }) describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { @@ -341,9 +378,12 @@ describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { await expect(got).rejects.toThrow('No App Management or Business Platform token found after ensuring authenticated') }) - test('returns app managment and business platform tokens if CLI token envvar is defined', async () => { + test('exchanges the organization automation token for App Management and Business Platform access', async () => { // Given - vi.mocked(getAppAutomationToken).mockReturnValue('custom_cli_token') + vi.mocked(getAutomationToken).mockReturnValue({ + value: 'organization_automation_token', + source: 'organization', + }) vi.mocked(exchangeAppAutomationTokenForAppManagementAccessToken).mockResolvedValueOnce({ accessToken: 'app-management-token', userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', @@ -362,6 +402,31 @@ describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', businessPlatformToken: 'business-platform-token', }) + expect(exchangeAppAutomationTokenForAppManagementAccessToken).toHaveBeenCalledWith('organization_automation_token') + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).toHaveBeenCalledWith( + 'organization_automation_token', + ) + expect(ensureAuthenticated).not.toHaveBeenCalled() + }) + + test('does not fall back to interactive authentication when a retry exchange fails', async () => { + // Given + vi.mocked(getAutomationToken).mockReturnValue({ + value: 'organization_automation_token', + source: 'organization', + }) + vi.mocked(exchangeAppAutomationTokenForAppManagementAccessToken).mockResolvedValueOnce({ + accessToken: 'app-management-token', + userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', + }) + vi.mocked(exchangeAppAutomationTokenForBusinessPlatformAccessToken).mockRejectedValueOnce( + new Error('Token exchange failed'), + ) + + // When/Then + await expect( + ensureAuthenticatedAppManagementAndBusinessPlatform({noPrompt: true, forceRefresh: true}), + ).rejects.toThrow('Token exchange failed') 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..b4e72b94fe1 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -1,6 +1,6 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' -import {getAppAutomationToken} from './environment.js' +import {getAutomationToken} from './environment.js' import {AbortError, BugError} from './error.js' import {outputContent, outputToken, outputDebug} from './output.js' import * as sessionStore from '../../private/node/session/store.js' @@ -135,8 +135,8 @@ export async function ensureAuthenticatedUser( /** * Ensure that we have a valid session to access the Partners API. - * If SHOPIFY_CLI_PARTNERS_TOKEN exists, that token will be used to obtain a valid Partners Token - * If SHOPIFY_CLI_PARTNERS_TOKEN exists, scopes will be ignored. + * If an automation token exists in the environment, it will be used to obtain a valid Partners token + * and scopes will be ignored. * * @param scopes - Optional array of extra scopes to authenticate with. * @param env - Optional environment variables to use. @@ -151,9 +151,9 @@ export async function ensureAuthenticatedPartners( outputDebug(outputContent`Ensuring that the user is authenticated with the Partners API with the following scopes: ${outputToken.json(scopes)} `) - const envToken = getAppAutomationToken() - if (envToken) { - const result = await exchangeCustomPartnerToken(envToken) + const automationToken = getAutomationToken(env) + if (automationToken) { + const result = await exchangeCustomPartnerToken(automationToken.value) return {token: result.accessToken, userId: result.userId} } const tokens = await ensureAuthenticated({partnersApi: {scopes}}, env, options) @@ -182,14 +182,14 @@ export async function ensureAuthenticatedAppManagementAndBusinessPlatform( ${outputToken.json(appManagementScopes)} `) - const envToken = getAppAutomationToken() - if (envToken) { - const appManagmentToken = await exchangeAppAutomationTokenForAppManagementAccessToken(envToken) - const businessPlatformToken = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(envToken) + const automationToken = getAutomationToken(env) + if (automationToken) { + const appManagementToken = await exchangeAppAutomationTokenForAppManagementAccessToken(automationToken.value) + const businessPlatformToken = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(automationToken.value) return { - appManagementToken: appManagmentToken.accessToken, - userId: appManagmentToken.userId, + appManagementToken: appManagementToken.accessToken, + userId: appManagementToken.userId, businessPlatformToken: businessPlatformToken.accessToken, } } @@ -300,6 +300,7 @@ ${outputToken.json(scopes)} /** * Ensure that we have a valid session to access the Business Platform API. + * If an automation token exists in the environment, that token will be used and scopes will be ignored. * * @param scopes - Optional array of extra scopes to authenticate with. * @param options - Optional extra options to use. @@ -312,6 +313,11 @@ export async function ensureAuthenticatedBusinessPlatform( outputDebug(outputContent`Ensuring that the user is authenticated with the Business Platform API with the following scopes: ${outputToken.json(scopes)} `) + const automationToken = getAutomationToken() + if (automationToken) { + const result = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(automationToken.value) + return result.accessToken + } const tokens = await ensureAuthenticated({businessPlatformApi: {scopes}}, process.env, options) if (!tokens.businessPlatform) { throw new BugError('No business-platform token found after ensuring authenticated')