Skip to content
Draft
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/business-platform-app-automation-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Authenticate Business Platform commands with `SHOPIFY_APP_AUTOMATION_TOKEN`, including organization-scoped tokens
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
}))
Expand Down
1 change: 1 addition & 0 deletions packages/cli-kit/src/private/node/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 17 additions & 5 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -347,7 +359,7 @@ describe('when existing session is valid', () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('ok')
vi.mocked(fetchSessions).mockResolvedValue(validSessions)
vi.mocked(getAppAutomationToken).mockReturnValue('custom_cli_token')
vi.mocked(getAutomationToken).mockReturnValue({value: 'custom_cli_token', source: 'app'})
const expected = {...validTokens, partners: 'custom_partners_token'}

// When
Expand Down Expand Up @@ -505,7 +517,7 @@ describe('getLastSeenUserIdAfterAuth', () => {
test('returns UUID based on partners token if present in environment', async () => {
// Given
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)
vi.mocked(getAppAutomationToken).mockReturnValue('partners-token-456')
vi.mocked(getAutomationToken).mockReturnValue({value: 'partners-token-456', source: 'partners'})

// When
const userId = await getLastSeenUserIdAfterAuth()
Expand Down Expand Up @@ -595,7 +607,7 @@ describe('setLastSeenUserIdAfterAuth', () => {
describe('getLastSeenAuthMethod', () => {
beforeEach(() => {
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)
vi.mocked(getAppAutomationToken).mockReturnValue(undefined)
vi.mocked(getAutomationToken).mockReturnValue(undefined)
vi.mocked(themeToken).mockReturnValue(undefined)
setLastSeenAuthMethod('none')
})
Expand Down Expand Up @@ -626,7 +638,7 @@ describe('getLastSeenAuthMethod', () => {

test('returns partners_token if there is a partners token in the environment', async () => {
// Given
vi.mocked(getAppAutomationToken).mockReturnValue('partners-token-456')
vi.mocked(getAutomationToken).mockReturnValue({value: 'partners-token-456', source: 'partners'})

// When
const method = await getLastSeenAuthMethod()
Expand Down
24 changes: 15 additions & 9 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string> {
const customToken = getAppAutomationToken() ?? themeToken()
const customToken = getAutomationToken()?.value ?? themeToken()
if (customToken) return nonRandomUUID(customToken)

if (userId) return userId
Expand Down Expand Up @@ -165,8 +165,8 @@ export async function getLastSeenAuthMethod(): Promise<AuthMethod> {

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) {
Expand Down Expand Up @@ -200,9 +200,16 @@ export interface EnsureAuthenticatedAdditionalOptions {
*/
export async function ensureAuthenticated(
applications: OAuthApplications,
_env?: NodeJS.ProcessEnv,
env = process.env,
{forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {},
): Promise<OAuthSession> {
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
Expand Down Expand Up @@ -270,12 +277,11 @@ ${outputToken.json(applications)}

const tokens = await tokensFor(applications, completeSession)

const envToken = getAppAutomationToken()
if (envToken && applications.partnersApi) {
tokens.partners = (await exchangeCustomPartnerToken(envToken)).accessToken
if (automationToken && applications.partnersApi) {
tokens.partners = (await exchangeCustomPartnerToken(automationToken.value)).accessToken
}

setLastSeenAuthMethod(envToken ? 'partners_token' : 'device_auth')
setLastSeenAuthMethod(automationToken ? 'partners_token' : 'device_auth')
setLastSeenUserIdAfterAuth(tokens.userId)
return tokens
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ const tokenExchangeMethods = [
},
{
tokenExchangeMethod: exchangeAppAutomationTokenForBusinessPlatformAccessToken,
expectedScopes: ['https://api.shopify.com/auth/destinations.readonly'],
expectedScopes: [],
expectedApi: 'business-platform',
expectedErrorName: 'Business Platform',
},
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/private/node/session/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/cli-kit/src/private/node/session/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
72 changes: 58 additions & 14 deletions packages/cli-kit/src/public/node/environment.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})

Expand Down
Loading
Loading