From 04e9511eb90628b8753309f688c5d9d0966e9ffd Mon Sep 17 00:00:00 2001 From: River Date: Fri, 2 Oct 2026 19:00:13 +0000 Subject: [PATCH 1/3] Add SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN getAppAutomationToken() now reads SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN first, then SHOPIFY_APP_AUTOMATION_TOKEN, then SHOPIFY_CLI_PARTNERS_TOKEN. Every place that already reads the automation token picks up organization tokens through it, including the store and organization commands, app commands and analytics. It returns no token when the variables can't be used: the organization variable set together with another one, or the selected variable set to an empty string. Those callers then fall back to ensureAuthenticated, which now reports the problem instead of starting a login. ensureAuthenticated also refuses to log in while the organization variable is set, so commands that only support a user login never fall back to a personal session. Co-authored-by: Eddie Gao --- .../cli-kit/src/private/node/constants.ts | 1 + .../cli-kit/src/private/node/session.test.ts | 33 +++++++++ packages/cli-kit/src/private/node/session.ts | 14 ++++ .../node/session/automation-token.test.ts | 67 +++++++++++++++++++ .../private/node/session/automation-token.ts | 57 ++++++++++++++++ .../src/public/node/environment.test.ts | 21 ++++++ .../cli-kit/src/public/node/environment.ts | 14 ++-- 7 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 packages/cli-kit/src/private/node/session/automation-token.test.ts create mode 100644 packages/cli-kit/src/private/node/session/automation-token.ts 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..282cdae63a2 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -20,6 +20,7 @@ import {ApplicationToken, IdentityToken, Sessions} from './session/schema.js' import {validateSession} from './session/validate.js' import {applicationId} from './session/identity.js' import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js' +import {automationTokenVariable, automationTokenVariablesProblem} from './session/automation-token.js' import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js' import * as fqdnModule from '../../public/node/context/fqdn.js' import {themeToken} from '../../public/node/context/local.js' @@ -113,6 +114,7 @@ vi.mock('./session/exchange') vi.mock('./session/scopes') vi.mock('./session/store') vi.mock('./session/validate') +vi.mock('./session/automation-token') vi.mock('../../public/node/api/partners.js') vi.mock('../../public/node/api/business-platform.js') vi.mock('../../store') @@ -153,6 +155,37 @@ beforeEach(() => { }) }) +describe('ensureAuthenticated with automation token variables', () => { + test('fails before logging in when the automation token variables are invalid', async () => { + // Given + vi.mocked(automationTokenVariablesProblem).mockReturnValue({ + message: 'SHOPIFY_APP_AUTOMATION_TOKEN is set but empty.', + tryMessage: 'Set it to an automation token, or unset it to log in with your Shopify account.', + }) + + // When + const got = ensureAuthenticated(defaultApplications) + + // Then + await expect(got).rejects.toThrow('SHOPIFY_APP_AUTOMATION_TOKEN is set but empty.') + expect(fetchSessions).not.toHaveBeenCalled() + expect(requestDeviceAuthorization).not.toHaveBeenCalled() + }) + + test('refuses to log in while the organization automation token is set', async () => { + // Given + vi.mocked(automationTokenVariable).mockReturnValue('SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN') + + // When + const got = ensureAuthenticated(defaultApplications) + + // Then + await expect(got).rejects.toThrow("This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.") + expect(fetchSessions).not.toHaveBeenCalled() + expect(requestDeviceAuthorization).not.toHaveBeenCalled() + }) +}) + describe('ensureAuthenticated when previous session is invalid', () => { test('executes complete auth flow if there is no session', async () => { // Given diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3eb9f9e5ee6..ca8f6a86355 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -12,8 +12,10 @@ import { import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js' +import {automationTokenVariable, automationTokenVariablesProblem} from './session/automation-token.js' import {isThemeAccessSession} from './api/rest.js' import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js' +import {environmentVariables} from './constants.js' import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js' import {outputContent, outputToken, outputDebug, outputCompleted} from '../../public/node/output.js' import {themeToken} from '../../public/node/context/local.js' @@ -203,6 +205,18 @@ export async function ensureAuthenticated( _env?: NodeJS.ProcessEnv, {forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {}, ): Promise { + // Commands that support automation tokens exchange them before calling this function, and getAppAutomationToken + // returns no token when the variables are invalid, so an invalid environment always reaches this check before any + // login starts. + const variablesProblem = automationTokenVariablesProblem() + if (variablesProblem) throw new AbortError(variablesProblem.message, variablesProblem.tryMessage) + if (automationTokenVariable() === environmentVariables.organizationAutomationToken) { + throw new AbortError( + `This command can't use ${environmentVariables.organizationAutomationToken}.`, + `Unset ${environmentVariables.organizationAutomationToken} to run it with your Shopify account.`, + ) + } + const fqdn = await identityFqdn() const previousStoreFqdn = applications.adminApi?.storeFqdn diff --git a/packages/cli-kit/src/private/node/session/automation-token.test.ts b/packages/cli-kit/src/private/node/session/automation-token.test.ts new file mode 100644 index 00000000000..a0ae272641e --- /dev/null +++ b/packages/cli-kit/src/private/node/session/automation-token.test.ts @@ -0,0 +1,67 @@ +import {automationTokenVariable, automationTokenVariablesProblem} from './automation-token.js' +import {environmentVariables} from '../constants.js' +import {describe, expect, test} from 'vitest' + +const ORGANIZATION = environmentVariables.organizationAutomationToken +const APP = environmentVariables.appAutomationToken +const PARTNERS = environmentVariables.partnersToken + +describe('automationTokenVariable', () => { + test.each([ + {name: 'no automation token variable', env: {}, expected: undefined}, + {name: 'the organization variable', env: {[ORGANIZATION]: 'org-token'}, expected: ORGANIZATION}, + {name: 'the app variable', env: {[APP]: 'app-token'}, expected: APP}, + {name: 'the Partners variable', env: {[PARTNERS]: 'partners-token'}, expected: PARTNERS}, + {name: 'the app variable over the Partners variable', env: {[APP]: 'a', [PARTNERS]: 'p'}, expected: APP}, + {name: 'an empty app variable over the Partners variable', env: {[APP]: '', [PARTNERS]: 'p'}, expected: APP}, + {name: 'the organization variable over the others', env: {[ORGANIZATION]: 'o', [APP]: 'a'}, expected: ORGANIZATION}, + ])('selects $name', ({env, expected}) => { + expect(automationTokenVariable(env)).toBe(expected) + }) +}) + +describe('automationTokenVariablesProblem', () => { + test.each([ + {name: 'no automation token variable', env: {}}, + {name: 'only the organization variable', env: {[ORGANIZATION]: 'org-token'}}, + {name: 'only the app variable', env: {[APP]: 'app-token'}}, + {name: 'only the Partners variable', env: {[PARTNERS]: 'partners-token'}}, + {name: 'both legacy variables', env: {[APP]: 'app-token', [PARTNERS]: 'partners-token'}}, + {name: 'an empty Partners variable behind the app variable', env: {[APP]: 'app-token', [PARTNERS]: ''}}, + ])('accepts $name', ({env}) => { + expect(automationTokenVariablesProblem(env)).toBeUndefined() + }) + + test.each([ + { + name: 'the organization variable with the app variable', + env: {[ORGANIZATION]: 'org-token', [APP]: 'app-token'}, + message: `${ORGANIZATION} can't be set together with ${APP}.`, + }, + { + name: 'the organization variable with the Partners variable', + env: {[ORGANIZATION]: 'org-token', [PARTNERS]: 'partners-token'}, + message: `${ORGANIZATION} can't be set together with ${PARTNERS}.`, + }, + { + name: 'the organization variable with both legacy variables', + env: {[ORGANIZATION]: 'org-token', [APP]: 'app-token', [PARTNERS]: 'partners-token'}, + message: `${ORGANIZATION} can't be set together with ${APP} or ${PARTNERS}.`, + }, + { + name: 'the organization variable with an empty app variable', + env: {[ORGANIZATION]: 'org-token', [APP]: ''}, + message: `${ORGANIZATION} can't be set together with ${APP}.`, + }, + {name: 'an empty organization variable', env: {[ORGANIZATION]: ''}, message: `${ORGANIZATION} is set but empty.`}, + {name: 'an empty app variable', env: {[APP]: ''}, message: `${APP} is set but empty.`}, + { + name: 'an empty app variable, even when the Partners variable has a value', + env: {[APP]: '', [PARTNERS]: 'partners-token'}, + message: `${APP} is set but empty.`, + }, + {name: 'an empty Partners variable', env: {[PARTNERS]: ''}, message: `${PARTNERS} is set but empty.`}, + ])('rejects $name', ({env, message}) => { + expect(automationTokenVariablesProblem(env)?.message).toBe(message) + }) +}) diff --git a/packages/cli-kit/src/private/node/session/automation-token.ts b/packages/cli-kit/src/private/node/session/automation-token.ts new file mode 100644 index 00000000000..bff83e424d3 --- /dev/null +++ b/packages/cli-kit/src/private/node/session/automation-token.ts @@ -0,0 +1,57 @@ +import {environmentVariables} from '../constants.js' + +const ORGANIZATION_TOKEN = environmentVariables.organizationAutomationToken +const APP_TOKEN = environmentVariables.appAutomationToken +const PARTNERS_TOKEN = environmentVariables.partnersToken + +interface AutomationTokenVariablesProblem { + message: string + tryMessage: string +} + +/** + * Returns the name of the automation token variable the CLI authenticates with: the first one that is set, in + * the order SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN, SHOPIFY_APP_AUTOMATION_TOKEN, SHOPIFY_CLI_PARTNERS_TOKEN. + * + * A variable set to an empty string still counts as set, so `automationTokenVariablesProblem` can report it. + * + * @param env - Environment variables to read. + * @returns The variable name, or undefined when none of them is set. + */ +export function automationTokenVariable(env: NodeJS.ProcessEnv = process.env): string | undefined { + return [ORGANIZATION_TOKEN, APP_TOKEN, PARTNERS_TOKEN].find((name) => env[name] !== undefined) +} + +/** + * Explains why the automation token variables can't be used, so a misconfigured environment fails instead of + * falling back to the logged-in user. + * + * - SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN can't be set together with SHOPIFY_APP_AUTOMATION_TOKEN or + * SHOPIFY_CLI_PARTNERS_TOKEN. + * - The selected variable can't be empty. + * + * @param env - Environment variables to check. + * @returns The problem to report, or undefined when the variables can be used. + */ +export function automationTokenVariablesProblem( + env: NodeJS.ProcessEnv = process.env, +): AutomationTokenVariablesProblem | undefined { + if (env[ORGANIZATION_TOKEN] !== undefined) { + const conflictingVariables = [APP_TOKEN, PARTNERS_TOKEN].filter((name) => env[name] !== undefined) + if (conflictingVariables.length > 0) { + return { + message: `${ORGANIZATION_TOKEN} can't be set together with ${conflictingVariables.join(' or ')}.`, + tryMessage: `Unset ${conflictingVariables.join(' and ')}, or unset ${ORGANIZATION_TOKEN}.`, + } + } + } + + const variable = automationTokenVariable(env) + if (variable && env[variable] === '') { + return { + message: `${variable} is set but empty.`, + tryMessage: 'Set it to an automation token, or unset it to log in with your Shopify account.', + } + } + return undefined +} diff --git a/packages/cli-kit/src/public/node/environment.test.ts b/packages/cli-kit/src/public/node/environment.test.ts index d69dc6e4baa..d878337ec92 100644 --- a/packages/cli-kit/src/public/node/environment.test.ts +++ b/packages/cli-kit/src/public/node/environment.test.ts @@ -3,6 +3,7 @@ import {environmentVariables, systemEnvironmentVariables} from '../../private/no 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] @@ -32,6 +33,26 @@ describe('getAppAutomationToken', () => { test('returns undefined when neither env var is set', () => { expect(getAppAutomationToken()).toBeUndefined() }) + + test('returns SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN when set', () => { + process.env[environmentVariables.organizationAutomationToken] = 'org-token' + + expect(getAppAutomationToken()).toBe('org-token') + }) + + test('returns undefined when SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set together with another variable', () => { + process.env[environmentVariables.organizationAutomationToken] = 'org-token' + process.env[environmentVariables.appAutomationToken] = 'new-token' + + expect(getAppAutomationToken()).toBeUndefined() + }) + + test('returns undefined when the selected variable is empty, even if a later one has a value', () => { + process.env[environmentVariables.appAutomationToken] = '' + process.env[environmentVariables.partnersToken] = 'old-token' + + expect(getAppAutomationToken()).toBeUndefined() + }) }) describe('getBackendPort', () => { diff --git a/packages/cli-kit/src/public/node/environment.ts b/packages/cli-kit/src/public/node/environment.ts index 981e936250e..31e7448f3fc 100644 --- a/packages/cli-kit/src/public/node/environment.ts +++ b/packages/cli-kit/src/public/node/environment.ts @@ -2,6 +2,7 @@ import {nonRandomUUID} from './crypto.js' import {isTruthy} from './context/utilities.js' import {sniffForJson} from './path.js' import {environmentVariables, systemEnvironmentVariables} from '../../private/node/constants.js' +import {automationTokenVariable, automationTokenVariablesProblem} from '../../private/node/session/automation-token.js' /** * It returns the environment variables of the environment @@ -18,14 +19,19 @@ export function getEnvironmentVariables(): NodeJS.ProcessEnv { } /** - * Returns the value of the SHOPIFY_APP_AUTOMATION_TOKEN environment variable, - * falling back to the deprecated SHOPIFY_CLI_PARTNERS_TOKEN. + * Returns the automation token the CLI authenticates with, from the first of these variables that is set: + * SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN, SHOPIFY_APP_AUTOMATION_TOKEN, or the deprecated SHOPIFY_CLI_PARTNERS_TOKEN. * - * @returns The app automation token value, or undefined if neither env var is set. + * Returns undefined when the variables can't be used (an empty value, or the organization variable set alongside + * another one). Callers then fall back to the login flow, which reports the problem instead of logging in. + * + * @returns The automation token, or undefined if there is no usable one. */ export function getAppAutomationToken(): string | undefined { const env = getEnvironmentVariables() - return env[environmentVariables.appAutomationToken] ?? env[environmentVariables.partnersToken] + if (automationTokenVariablesProblem(env)) return undefined + const variable = automationTokenVariable(env) + return variable ? env[variable] : undefined } /** From 4e5ba49274d8b426601975ad1944dbe8df03937d Mon Sep 17 00:00:00 2001 From: River Date: Wed, 7 Oct 2026 14:15:44 +0000 Subject: [PATCH 2/3] Refuse saved store logins while the organization token is set store execute, store bulk and store graphiql run on a login saved by `shopify store auth`. While SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set they now fail with "This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN", the same error commands that need the user's own login already give, so nobody assumes the token gives store or Admin API access. The check is exported from cli-kit as ensureNoOrganizationAutomationToken, and ensureAuthenticated now uses it too. It runs in each command rather than in the shared saved-login loader, because `shopify store auth` reads saved logins through that loader and must keep working as if the variable weren't set. Co-authored-by: Eddie Gao --- packages/cli-kit/src/private/node/session.ts | 12 +++------ .../cli-kit/src/public/node/session.test.ts | 22 +++++++++++++++ packages/cli-kit/src/public/node/session.ts | 18 +++++++++++++ .../services/store/auth/admin-session.test.ts | 14 +++++++++- .../cli/services/store/auth/admin-session.ts | 3 ++- .../src/cli/services/store/auth/index.test.ts | 27 ++++++++++++++++++- .../store/bulk/bulk-admin-context.test.ts | 14 +++++++++- .../services/store/bulk/bulk-admin-context.ts | 3 ++- .../src/cli/services/store/graphiql.test.ts | 14 ++++++++++ .../store/src/cli/services/store/graphiql.ts | 2 ++ 10 files changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index ca8f6a86355..adfbc8a713b 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -12,17 +12,16 @@ import { import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js' -import {automationTokenVariable, automationTokenVariablesProblem} from './session/automation-token.js' +import {automationTokenVariablesProblem} from './session/automation-token.js' import {isThemeAccessSession} from './api/rest.js' import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js' -import {environmentVariables} from './constants.js' import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js' import {outputContent, outputToken, outputDebug, outputCompleted} from '../../public/node/output.js' 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 {AdminSession, logout} from '../../public/node/session.js' +import {AdminSession, ensureNoOrganizationAutomationToken, logout} from '../../public/node/session.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {isEmpty} from '../../public/common/object.js' import {businessPlatformRequest} from '../../public/node/api/business-platform.js' @@ -210,12 +209,7 @@ export async function ensureAuthenticated( // login starts. const variablesProblem = automationTokenVariablesProblem() if (variablesProblem) throw new AbortError(variablesProblem.message, variablesProblem.tryMessage) - if (automationTokenVariable() === environmentVariables.organizationAutomationToken) { - throw new AbortError( - `This command can't use ${environmentVariables.organizationAutomationToken}.`, - `Unset ${environmentVariables.organizationAutomationToken} to run it with your Shopify account.`, - ) - } + ensureNoOrganizationAutomationToken() const fqdn = await identityFqdn() diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index 75578e298ea..a0785a5a578 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -6,6 +6,7 @@ import { ensureAuthenticatedPartners, ensureAuthenticatedStorefront, ensureAuthenticatedThemes, + ensureNoOrganizationAutomationToken, findSessionIdByAlias, setCurrentSessionAlias, setLastSeenUserId, @@ -21,6 +22,7 @@ import { setLastSeenUserIdAfterAuth, } from '../../private/node/session.js' import * as sessionStore from '../../private/node/session/store.js' +import {automationTokenVariable} from '../../private/node/session/automation-token.js' import {ApplicationToken} from '../../private/node/session/schema.js' import { exchangeCustomPartnerToken, @@ -41,6 +43,7 @@ const partnersToken: ApplicationToken = { vi.mock('../../private/node/session.js') vi.mock('../../private/node/session/exchange.js') vi.mock('../../private/node/session/store.js') +vi.mock('../../private/node/session/automation-token.js') vi.mock('./environment.js') vi.mock('./http.js') @@ -52,6 +55,25 @@ describe('store command analytics session helpers', () => { }) }) +describe('ensureNoOrganizationAutomationToken', () => { + test('fails while SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set', () => { + vi.mocked(automationTokenVariable).mockReturnValue('SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN') + + expect(() => ensureNoOrganizationAutomationToken()).toThrow( + "This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.", + ) + }) + + test.each([undefined, 'SHOPIFY_APP_AUTOMATION_TOKEN', 'SHOPIFY_CLI_PARTNERS_TOKEN'])( + 'does nothing when the automation token variable is %s', + (variable) => { + vi.mocked(automationTokenVariable).mockReturnValue(variable) + + expect(() => ensureNoOrganizationAutomationToken()).not.toThrow() + }, + ) +}) + describe('findSessionIdByAlias', () => { test('returns the matching session ID without selecting it', async () => { // Given diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 93b7984b5d6..659f9aa89d6 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -4,6 +4,8 @@ import {getAppAutomationToken} 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' +import {automationTokenVariable} from '../../private/node/session/automation-token.js' +import {environmentVariables} from '../../private/node/constants.js' import { exchangeCustomPartnerToken, exchangeAppAutomationTokenForAppManagementAccessToken, @@ -341,6 +343,22 @@ ${outputToken.json(scopes)} return tokens.businessPlatform } +/** + * Fails when SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set, for commands that can't run with that token. + * + * Organization automation tokens can't log in as a user or call a store's Admin API, so commands that need either + * refuse to run instead of quietly using the person's own login. `ensureAuthenticated` runs this check before any + * login. Commands that run on a login saved by `shopify store auth` call it before loading that login. + * + * @throws AbortError when SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set. + */ +export function ensureNoOrganizationAutomationToken(): void { + const variable = environmentVariables.organizationAutomationToken + if (automationTokenVariable() !== variable) return + + throw new AbortError(`This command can't use ${variable}.`, `Unset ${variable} to run it with your Shopify account.`) +} + /** * Logout from Shopify. * diff --git a/packages/store/src/cli/services/store/auth/admin-session.test.ts b/packages/store/src/cli/services/store/auth/admin-session.test.ts index 35773362d74..ec960040770 100644 --- a/packages/store/src/cli/services/store/auth/admin-session.test.ts +++ b/packages/store/src/cli/services/store/auth/admin-session.test.ts @@ -1,7 +1,8 @@ import {loadAdminSessionFromStoreAuth} from './admin-session.js' import {loadStoredStoreSession} from './session-lifecycle.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' vi.mock('./session-lifecycle.js') @@ -44,4 +45,15 @@ describe('loadAdminSessionFromStoreAuth', () => { expect(recordStoreFqdnMetadata).not.toHaveBeenCalled() expect(setLastSeenUserId).not.toHaveBeenCalled() }) + + test('refuses to use stored store auth while the organization automation token is set', async () => { + vi.mocked(ensureNoOrganizationAutomationToken).mockImplementation(() => { + throw new AbortError("This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.") + }) + + await expect(loadAdminSessionFromStoreAuth('shop.myshopify.com')).rejects.toThrow( + "This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.", + ) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + }) }) diff --git a/packages/store/src/cli/services/store/auth/admin-session.ts b/packages/store/src/cli/services/store/auth/admin-session.ts index e83239eb946..7ec7c91abaf 100644 --- a/packages/store/src/cli/services/store/auth/admin-session.ts +++ b/packages/store/src/cli/services/store/auth/admin-session.ts @@ -1,7 +1,7 @@ import {loadStoredStoreSession} from './session-lifecycle.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session' import type {AdminSession} from '@shopify/cli-kit/node/session' import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' @@ -9,6 +9,7 @@ export async function loadAdminSessionFromStoreAuth(store: string): Promise<{ adminSession: AdminSession session: StoredStoreAppSession }> { + ensureNoOrganizationAutomationToken() const session = await loadStoredStoreSession(normalizeStoreFqdn(store)) await recordStoreFqdnMetadata(session.store, true) setLastSeenUserId(session.userId) diff --git a/packages/store/src/cli/services/store/auth/index.test.ts b/packages/store/src/cli/services/store/auth/index.test.ts index bf9988a27ec..f33e3732854 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -2,7 +2,8 @@ import {authenticateStoreWithApp} from './index.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {setStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/store-auth-session') @@ -104,6 +105,30 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp runs while the organization automation token is set', async () => { + vi.mocked(ensureNoOrganizationAutomationToken).mockImplementation(() => { + throw new AbortError("This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.") + }) + + const result = await authenticateStoreWithApp( + {store: 'shop.myshopify.com', scopes: 'read_products'}, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: vi.fn().mockResolvedValue('abc123'), + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: {openingBrowser: vi.fn(), manualAuthUrl: vi.fn(), success: vi.fn()}, + }, + ) + + expect(result.userId).toBe('42') + expect(ensureNoOrganizationAutomationToken).not.toHaveBeenCalled() + }) + test('authenticateStoreWithApp opens a loopback handoff URL when a signup JWT is provided', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { diff --git a/packages/store/src/cli/services/store/bulk/bulk-admin-context.test.ts b/packages/store/src/cli/services/store/bulk/bulk-admin-context.test.ts index a2c8bd3acf1..50d00f2ca6c 100644 --- a/packages/store/src/cli/services/store/bulk/bulk-admin-context.test.ts +++ b/packages/store/src/cli/services/store/bulk/bulk-admin-context.test.ts @@ -1,7 +1,8 @@ import {prepareBulkAdminContext} from './bulk-admin-context.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) @@ -32,4 +33,15 @@ describe('prepareBulkAdminContext', () => { expect(setLastSeenUserId).toHaveBeenCalledWith('42') expect(result).toEqual({token: 'token', storeFqdn: store}) }) + + test('refuses to use stored store auth while the organization automation token is set', async () => { + vi.mocked(ensureNoOrganizationAutomationToken).mockImplementation(() => { + throw new AbortError("This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.") + }) + + await expect(prepareBulkAdminContext(store)).rejects.toThrow( + "This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.", + ) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + }) }) diff --git a/packages/store/src/cli/services/store/bulk/bulk-admin-context.ts b/packages/store/src/cli/services/store/bulk/bulk-admin-context.ts index 3059e4de999..74f62e5d821 100644 --- a/packages/store/src/cli/services/store/bulk/bulk-admin-context.ts +++ b/packages/store/src/cli/services/store/bulk/bulk-admin-context.ts @@ -1,6 +1,6 @@ import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session' import type {AdminSession} from '@shopify/cli-kit/node/session' /** @@ -12,6 +12,7 @@ import type {AdminSession} from '@shopify/cli-kit/node/session' * @returns The Admin session for the stored auth. */ export async function prepareBulkAdminContext(store: string): Promise { + ensureNoOrganizationAutomationToken() const session = await loadStoredStoreSession(store) await recordStoreFqdnMetadata(session.store, true) setLastSeenUserId(session.userId) diff --git a/packages/store/src/cli/services/store/graphiql.test.ts b/packages/store/src/cli/services/store/graphiql.test.ts index 8995cf31f97..4308215d5d8 100644 --- a/packages/store/src/cli/services/store/graphiql.test.ts +++ b/packages/store/src/cli/services/store/graphiql.test.ts @@ -1,10 +1,13 @@ import {openStoreGraphiQL} from './graphiql.js' import {loadStoredStoreSession} from './auth/session-lifecycle.js' import {AbortController} from '@shopify/cli-kit/node/abort' +import {AbortError} from '@shopify/cli-kit/node/error' import {generateRandomGraphiQLKey, runGraphiQLSession} from '@shopify/cli-kit/node/graphiql/session' +import {ensureNoOrganizationAutomationToken} from '@shopify/cli-kit/node/session' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/graphiql/session') +vi.mock('@shopify/cli-kit/node/session') vi.mock('./auth/session-lifecycle.js') const mockedRunGraphiQLSession = vi.mocked(runGraphiQLSession) @@ -55,6 +58,17 @@ describe('openStoreGraphiQL', () => { ) }) + test('refuses to start while the organization automation token is set', async () => { + vi.mocked(ensureNoOrganizationAutomationToken).mockImplementation(() => { + throw new AbortError("This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.") + }) + + await expect(openStoreGraphiQL({store: 'shop.myshopify.com'})).rejects.toThrow( + "This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.", + ) + expect(mockedRunGraphiQLSession).not.toHaveBeenCalled() + }) + test('uses a TokenProvider backed by loadStoredStoreSession', async () => { await openStoreGraphiQL({ store: 'shop.myshopify.com', diff --git a/packages/store/src/cli/services/store/graphiql.ts b/packages/store/src/cli/services/store/graphiql.ts index 1167e29b848..0ac18f6d75a 100644 --- a/packages/store/src/cli/services/store/graphiql.ts +++ b/packages/store/src/cli/services/store/graphiql.ts @@ -3,6 +3,7 @@ import {TokenProvider} from '@shopify/cli-kit/node/graphiql/server' import {generateRandomGraphiQLKey, runGraphiQLSession} from '@shopify/cli-kit/node/graphiql/session' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {AbortSignal} from '@shopify/cli-kit/node/abort' +import {ensureNoOrganizationAutomationToken} from '@shopify/cli-kit/node/session' interface OpenStoreGraphiQLOptions { store: string @@ -24,6 +25,7 @@ interface OpenStoreGraphiQLOptions { * process to be aborted (Ctrl+C) before shutting down. */ export async function openStoreGraphiQL(options: OpenStoreGraphiQLOptions): Promise { + ensureNoOrganizationAutomationToken() const tokenProvider = createStoredSessionTokenProvider(options.store) const key = generateRandomGraphiQLKey() From 03d54a6bf14c912eabf6395ac95e9bba2439e64b Mon Sep 17 00:00:00 2001 From: River Date: Wed, 7 Oct 2026 14:15:45 +0000 Subject: [PATCH 3/3] Add a changeset for the empty automation token variable error Co-authored-by: Eddie Gao --- .changeset/empty-automation-token-variable.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/empty-automation-token-variable.md diff --git a/.changeset/empty-automation-token-variable.md b/.changeset/empty-automation-token-variable.md new file mode 100644 index 00000000000..84df421ca6e --- /dev/null +++ b/.changeset/empty-automation-token-variable.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Fail with a clear error, instead of logging in with your Shopify account, when `SHOPIFY_APP_AUTOMATION_TOKEN` or `SHOPIFY_CLI_PARTNERS_TOKEN` is set but empty