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 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..adfbc8a713b 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -12,6 +12,7 @@ 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 {automationTokenVariablesProblem} from './session/automation-token.js' import {isThemeAccessSession} from './api/rest.js' import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js' import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js' @@ -20,7 +21,7 @@ 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' @@ -203,6 +204,13 @@ 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) + ensureNoOrganizationAutomationToken() + 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 } /** 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()