From ceeda1234a68fb655dc721ea85f663b9dca246f2 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Sun, 6 Sep 2026 23:06:10 +0300 Subject: [PATCH 1/3] Authenticate Business Platform commands with app automation tokens Business Platform commands could not run with SHOPIFY_APP_AUTOMATION_TOKEN set. ensureAuthenticated() only exchanged the environment token when applications.partnersApi was requested, so a BP-only command fell through to the device authorization flow and failed on throwOnNoPrompt in any non-interactive environment. ensureAuthenticatedBusinessPlatform() now exchanges the environment token directly, matching what ensureAuthenticatedPartners() and ensureAuthenticatedAppManagement() already do. The exchange also stops asking for scopes. It previously requested destinations.readonly, and widening that to include organization.store-management for organization-scoped tokens turned out to break app-scoped ones: Identity refuses to grant a scope the subject token does not already hold, so an app-scoped token was rejected with invalid_request :: Invalid 'scope' value: new scopes must not be specified Requesting no scope makes Identity grant whatever the token carries, narrowed to Business Platform. An organization-scoped token comes back with store management and an app-scoped token does not, which is the distinction we want, and neither has to be special-cased here. An empty scope string behaves the same as omitting the parameter, so requestAppToken() needs no change. Verified against local Identity with both token kinds: `organization list` and `store list` work with either, and `store create dev` authenticates with an organization-scoped token. An app-scoped token authenticates but is refused by Business Platform for store management, as intended. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/f5485e0b-dc2b-4459-9b5d-7968b926fcfb --- .../business-platform-app-automation-token.md | 5 +++++ .../src/private/node/session/exchange.test.ts | 2 +- .../src/private/node/session/scopes.test.ts | 4 ++-- .../cli-kit/src/private/node/session/scopes.ts | 5 ++++- .../cli-kit/src/public/node/session.test.ts | 17 +++++++++++++++++ packages/cli-kit/src/public/node/session.ts | 6 ++++++ 6 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 .changeset/business-platform-app-automation-token.md 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/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/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index 449992bb4b8..ad924dc4b19 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -308,6 +308,23 @@ describe('ensureAuthenticatedBusinessPlatform', () => { // Then await expect(got).rejects.toThrow(`No business-platform token`) }) + + test('exchanges the app automation token if envvar is defined', async () => { + // Given + vi.mocked(exchangeAppAutomationTokenForBusinessPlatformAccessToken).mockResolvedValueOnce({ + accessToken: 'business_platform_token_from_env', + userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d', + }) + vi.mocked(getAppAutomationToken).mockReturnValue('custom_app_automation_token') + + // When + const got = await ensureAuthenticatedBusinessPlatform() + + // Then + expect(got).toEqual('business_platform_token_from_env') + expect(exchangeAppAutomationTokenForBusinessPlatformAccessToken).toHaveBeenCalledWith('custom_app_automation_token') + expect(ensureAuthenticated).not.toHaveBeenCalled() + }) }) describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 15be5d39cbf..97265616af3 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -300,6 +300,7 @@ ${outputToken.json(scopes)} /** * Ensure that we have a valid session to access the Business Platform API. + * If an app 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 envToken = getAppAutomationToken() + if (envToken) { + const result = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(envToken) + 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') From 16d68483d5c0a6bb3a041601e7ae74166e21a639 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Sat, 19 Sep 2026 10:38:45 -0400 Subject: [PATCH 2/3] Support organization automation tokens Select organization, app, and deprecated Partners automation tokens through one canonical helper, reject ambiguous organization/app credentials, and keep automation exchanges authoritative for Business Platform and App Management retries. --- .../app-management-client.session.test.ts | 70 ++++++++++++++++++ .../app-management-client.ts | 4 +- .../cli-kit/src/private/node/constants.ts | 1 + .../cli-kit/src/private/node/session.test.ts | 10 +-- packages/cli-kit/src/private/node/session.ts | 18 ++--- .../src/public/node/environment.test.ts | 72 +++++++++++++++---- .../cli-kit/src/public/node/environment.ts | 49 +++++++++++-- .../cli-kit/src/public/node/session.test.ts | 62 ++++++++++++++-- packages/cli-kit/src/public/node/session.ts | 32 ++++----- 9 files changed, 260 insertions(+), 58 deletions(-) create mode 100644 packages/app/src/cli/utilities/developer-platform-client/app-management-client.session.test.ts 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..bd8d6fb491c 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' @@ -347,7 +347,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 +505,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 +595,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 +626,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..c786cabd5e7 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,10 @@ 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) const fqdn = await identityFqdn() const previousStoreFqdn = applications.adminApi?.storeFqdn @@ -270,12 +271,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/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 ad924dc4b19..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([]) @@ -309,20 +309,40 @@ describe('ensureAuthenticatedBusinessPlatform', () => { await expect(got).rejects.toThrow(`No business-platform token`) }) - test('exchanges the app automation token if envvar is defined', async () => { + 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(getAppAutomationToken).mockReturnValue('custom_app_automation_token') + 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('custom_app_automation_token') + 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() }) }) @@ -358,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', @@ -379,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 97265616af3..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,7 +300,7 @@ ${outputToken.json(scopes)} /** * Ensure that we have a valid session to access the Business Platform API. - * If an app automation token exists in the environment, that token will be used and scopes will be ignored. + * 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. @@ -313,9 +313,9 @@ 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 envToken = getAppAutomationToken() - if (envToken) { - const result = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(envToken) + const automationToken = getAutomationToken() + if (automationToken) { + const result = await exchangeAppAutomationTokenForBusinessPlatformAccessToken(automationToken.value) return result.accessToken } const tokens = await ensureAuthenticated({businessPlatformApi: {scopes}}, process.env, options) From c821e62c286407de27263f2622020cb052f58888 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Sat, 19 Sep 2026 14:22:27 -0400 Subject: [PATCH 3/3] Prevent organization token fallback to human sessions --- packages/cli-kit/src/private/node/session.test.ts | 12 ++++++++++++ packages/cli-kit/src/private/node/session.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index bd8d6fb491c..ba4ea7ef2c7 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -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') diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index c786cabd5e7..5e8722b748e 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -204,6 +204,12 @@ export async function ensureAuthenticated( {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