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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/service-account-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Add `SHOPIFY_SERVICE_ACCOUNT_TOKEN` to run `shopify organization list`, `shopify store list`, `shopify store create dev`, `shopify store info`, `shopify store delete` and app commands with a service account token. It can't be set together with `SHOPIFY_APP_AUTOMATION_TOKEN` or `SHOPIFY_CLI_PARTNERS_TOKEN`, and commands that need your Shopify account or a store's Admin API access, such as `shopify store execute`, refuse to run while it's set.
2 changes: 1 addition & 1 deletion packages/cli-kit/src/private/node/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const environmentVariables = {
env: 'SHOPIFY_CLI_ENV',
noAnalytics: 'SHOPIFY_CLI_NO_ANALYTICS',
optOutInstrumentation: 'OPT_OUT_INSTRUMENTATION',
organizationAutomationToken: 'SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN',
serviceAccountToken: 'SHOPIFY_SERVICE_ACCOUNT_TOKEN',
appAutomationToken: 'SHOPIFY_APP_AUTOMATION_TOKEN',
partnersToken: 'SHOPIFY_CLI_PARTNERS_TOKEN',
runAsUser: 'SHOPIFY_RUN_AS_USER',
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,15 +172,15 @@ describe('ensureAuthenticated with automation token variables', () => {
expect(requestDeviceAuthorization).not.toHaveBeenCalled()
})

test('refuses to log in while the organization automation token is set', async () => {
test('refuses to log in while the service account token is set', async () => {
// Given
vi.mocked(automationTokenVariable).mockReturnValue('SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN')
vi.mocked(automationTokenVariable).mockReturnValue('SHOPIFY_SERVICE_ACCOUNT_TOKEN')

// When
const got = ensureAuthenticated(defaultApplications)

// Then
await expect(got).rejects.toThrow("This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.")
await expect(got).rejects.toThrow("This command can't use SHOPIFY_SERVICE_ACCOUNT_TOKEN.")
expect(fetchSessions).not.toHaveBeenCalled()
expect(requestDeviceAuthorization).not.toHaveBeenCalled()
})
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,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, ensureNoOrganizationAutomationToken, logout} from '../../public/node/session.js'
import {AdminSession, ensureNoServiceAccountToken, 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'
Expand Down Expand Up @@ -209,7 +209,7 @@ export async function ensureAuthenticated(
// login starts.
const variablesProblem = automationTokenVariablesProblem()
if (variablesProblem) throw new AbortError(variablesProblem.message, variablesProblem.tryMessage)
ensureNoOrganizationAutomationToken()
ensureNoServiceAccountToken()

const fqdn = await identityFqdn()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@ import {automationTokenVariable, automationTokenVariablesProblem} from './automa
import {environmentVariables} from '../constants.js'
import {describe, expect, test} from 'vitest'

const ORGANIZATION = environmentVariables.organizationAutomationToken
const SERVICE_ACCOUNT = environmentVariables.serviceAccountToken
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 service account variable',
env: {[SERVICE_ACCOUNT]: 'service-account-token'},
expected: SERVICE_ACCOUNT,
},
{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},
{
name: 'the service account variable over the others',
env: {[SERVICE_ACCOUNT]: 'o', [APP]: 'a'},
expected: SERVICE_ACCOUNT,
},
])('selects $name', ({env, expected}) => {
expect(automationTokenVariable(env)).toBe(expected)
})
Expand All @@ -23,7 +31,7 @@ describe('automationTokenVariable', () => {
describe('automationTokenVariablesProblem', () => {
test.each([
{name: 'no automation token variable', env: {}},
{name: 'only the organization variable', env: {[ORGANIZATION]: 'org-token'}},
{name: 'only the service account variable', env: {[SERVICE_ACCOUNT]: 'service-account-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'}},
Expand All @@ -34,26 +42,30 @@ describe('automationTokenVariablesProblem', () => {

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 service account variable with the app variable',
env: {[SERVICE_ACCOUNT]: 'service-account-token', [APP]: 'app-token'},
message: `${SERVICE_ACCOUNT} can't be set together with ${APP}.`,
},
{
name: 'the service account variable with the Partners variable',
env: {[SERVICE_ACCOUNT]: 'service-account-token', [PARTNERS]: 'partners-token'},
message: `${SERVICE_ACCOUNT} can't be set together with ${PARTNERS}.`,
},
{
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 service account variable with both legacy variables',
env: {[SERVICE_ACCOUNT]: 'service-account-token', [APP]: 'app-token', [PARTNERS]: 'partners-token'},
message: `${SERVICE_ACCOUNT} can't be set together with ${APP} or ${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 service account variable with an empty app variable',
env: {[SERVICE_ACCOUNT]: 'service-account-token', [APP]: ''},
message: `${SERVICE_ACCOUNT} can't be set together with ${APP}.`,
},
{
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 service account variable',
env: {[SERVICE_ACCOUNT]: ''},
message: `${SERVICE_ACCOUNT} is set but empty.`,
},
{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',
Expand Down
14 changes: 7 additions & 7 deletions packages/cli-kit/src/private/node/session/automation-token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {environmentVariables} from '../constants.js'

const ORGANIZATION_TOKEN = environmentVariables.organizationAutomationToken
const SERVICE_ACCOUNT_TOKEN = environmentVariables.serviceAccountToken
const APP_TOKEN = environmentVariables.appAutomationToken
const PARTNERS_TOKEN = environmentVariables.partnersToken

Expand All @@ -11,22 +11,22 @@ interface AutomationTokenVariablesProblem {

/**
* 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.
* the order SHOPIFY_SERVICE_ACCOUNT_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)
return [SERVICE_ACCOUNT_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_SERVICE_ACCOUNT_TOKEN can't be set together with SHOPIFY_APP_AUTOMATION_TOKEN or
* SHOPIFY_CLI_PARTNERS_TOKEN.
* - The selected variable can't be empty.
*
Expand All @@ -36,12 +36,12 @@ export function automationTokenVariable(env: NodeJS.ProcessEnv = process.env): s
export function automationTokenVariablesProblem(
env: NodeJS.ProcessEnv = process.env,
): AutomationTokenVariablesProblem | undefined {
if (env[ORGANIZATION_TOKEN] !== undefined) {
if (env[SERVICE_ACCOUNT_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}.`,
message: `${SERVICE_ACCOUNT_TOKEN} can't be set together with ${conflictingVariables.join(' or ')}.`,
tryMessage: `Unset ${conflictingVariables.join(' and ')}, or unset ${SERVICE_ACCOUNT_TOKEN}.`,
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-kit/src/public/node/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +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.serviceAccountToken]
delete process.env[environmentVariables.appAutomationToken]
delete process.env[environmentVariables.partnersToken]
delete process.env[systemEnvironmentVariables.backendPort]
Expand Down Expand Up @@ -34,14 +34,14 @@ describe('getAppAutomationToken', () => {
expect(getAppAutomationToken()).toBeUndefined()
})

test('returns SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN when set', () => {
process.env[environmentVariables.organizationAutomationToken] = 'org-token'
test('returns SHOPIFY_SERVICE_ACCOUNT_TOKEN when set', () => {
process.env[environmentVariables.serviceAccountToken] = 'service-account-token'

expect(getAppAutomationToken()).toBe('org-token')
expect(getAppAutomationToken()).toBe('service-account-token')
})

test('returns undefined when SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set together with another variable', () => {
process.env[environmentVariables.organizationAutomationToken] = 'org-token'
test('returns undefined when SHOPIFY_SERVICE_ACCOUNT_TOKEN is set together with another variable', () => {
process.env[environmentVariables.serviceAccountToken] = 'service-account-token'
process.env[environmentVariables.appAutomationToken] = 'new-token'

expect(getAppAutomationToken()).toBeUndefined()
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/public/node/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export function getEnvironmentVariables(): NodeJS.ProcessEnv {

/**
* 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.
* SHOPIFY_SERVICE_ACCOUNT_TOKEN, SHOPIFY_APP_AUTOMATION_TOKEN, or the deprecated SHOPIFY_CLI_PARTNERS_TOKEN.
*
* Returns undefined when the variables can't be used (an empty value, or the organization variable set alongside
* Returns undefined when the variables can't be used (an empty value, or the service account 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.
Expand Down
14 changes: 6 additions & 8 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ensureAuthenticatedPartners,
ensureAuthenticatedStorefront,
ensureAuthenticatedThemes,
ensureNoOrganizationAutomationToken,
ensureNoServiceAccountToken,
findSessionIdByAlias,
setCurrentSessionAlias,
setLastSeenUserId,
Expand Down Expand Up @@ -55,21 +55,19 @@ 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')
describe('ensureNoServiceAccountToken', () => {
test('fails while SHOPIFY_SERVICE_ACCOUNT_TOKEN is set', () => {
vi.mocked(automationTokenVariable).mockReturnValue('SHOPIFY_SERVICE_ACCOUNT_TOKEN')

expect(() => ensureNoOrganizationAutomationToken()).toThrow(
"This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.",
)
expect(() => ensureNoServiceAccountToken()).toThrow("This command can't use SHOPIFY_SERVICE_ACCOUNT_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()
expect(() => ensureNoServiceAccountToken()).not.toThrow()
},
)
})
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,16 +344,16 @@ ${outputToken.json(scopes)}
}

/**
* Fails when SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN is set, for commands that can't run with that token.
* Fails when SHOPIFY_SERVICE_ACCOUNT_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
* Service account 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.
* @throws AbortError when SHOPIFY_SERVICE_ACCOUNT_TOKEN is set.
*/
export function ensureNoOrganizationAutomationToken(): void {
const variable = environmentVariables.organizationAutomationToken
export function ensureNoServiceAccountToken(): void {
const variable = environmentVariables.serviceAccountToken
if (automationTokenVariable() !== variable) return

throw new AbortError(`This command can't use ${variable}.`, `Unset ${variable} to run it with your Shopify account.`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {loadAdminSessionFromStoreAuth} from './admin-session.js'
import {loadStoredStoreSession} from './session-lifecycle.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {ensureNoServiceAccountToken, setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {AbortError} from '@shopify/cli-kit/node/error'
import {describe, expect, test, vi} from 'vitest'

Expand Down Expand Up @@ -46,13 +46,13 @@ describe('loadAdminSessionFromStoreAuth', () => {
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.")
test('refuses to use stored store auth while the service account token is set', async () => {
vi.mocked(ensureNoServiceAccountToken).mockImplementation(() => {
throw new AbortError("This command can't use SHOPIFY_SERVICE_ACCOUNT_TOKEN.")
})

await expect(loadAdminSessionFromStoreAuth('shop.myshopify.com')).rejects.toThrow(
"This command can't use SHOPIFY_ORGANIZATION_AUTOMATION_TOKEN.",
"This command can't use SHOPIFY_SERVICE_ACCOUNT_TOKEN.",
)
expect(loadStoredStoreSession).not.toHaveBeenCalled()
})
Expand Down
4 changes: 2 additions & 2 deletions packages/store/src/cli/services/store/auth/admin-session.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import {loadStoredStoreSession} from './session-lifecycle.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {ensureNoServiceAccountToken, 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'

export async function loadAdminSessionFromStoreAuth(store: string): Promise<{
adminSession: AdminSession
session: StoredStoreAppSession
}> {
ensureNoOrganizationAutomationToken()
ensureNoServiceAccountToken()
const session = await loadStoredStoreSession(normalizeStoreFqdn(store))
await recordStoreFqdnMetadata(session.store, true)
setLastSeenUserId(session.userId)
Expand Down
10 changes: 5 additions & 5 deletions packages/store/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 {ensureNoOrganizationAutomationToken, setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {ensureNoServiceAccountToken, setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {AbortError} from '@shopify/cli-kit/node/error'
import {describe, expect, test, vi} from 'vitest'

Expand Down Expand Up @@ -105,9 +105,9 @@ 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.")
test('authenticateStoreWithApp runs while the service account token is set', async () => {
vi.mocked(ensureNoServiceAccountToken).mockImplementation(() => {
throw new AbortError("This command can't use SHOPIFY_SERVICE_ACCOUNT_TOKEN.")
})

const result = await authenticateStoreWithApp(
Expand All @@ -126,7 +126,7 @@ describe('store auth service', () => {
)

expect(result.userId).toBe('42')
expect(ensureNoOrganizationAutomationToken).not.toHaveBeenCalled()
expect(ensureNoServiceAccountToken).not.toHaveBeenCalled()
})

test('authenticateStoreWithApp opens a loopback handoff URL when a signup JWT is provided', async () => {
Expand Down
Loading
Loading