Skip to content

Commit 7692bd1

Browse files
zzooeeyyRyanDJLee
andcommitted
Add CLI token support for app management and BP API
Co-authored-by: Ryan DJ Lee <[email protected]> Co-authored-by: Zoey Lan <[email protected]>
1 parent aeb56fc commit 7692bd1

File tree

9 files changed

+137
-58
lines changed

9 files changed

+137
-58
lines changed

Diff for: packages/app/src/cli/api/graphql/business-platform-destinations/generated/user-info.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co
55

66
export type UserInfoQueryVariables = Types.Exact<{[key: string]: never}>
77

8-
export type UserInfoQuery = {currentUserAccount?: {uuid: string; email: string} | null}
8+
export type UserInfoQuery = {
9+
currentUserAccount?: {uuid: string; email: string; organizations: {nodes: {name: string}[]}} | null
10+
}
911

1012
export const UserInfo = {
1113
kind: 'Document',
@@ -25,6 +27,30 @@ export const UserInfo = {
2527
selections: [
2628
{kind: 'Field', name: {kind: 'Name', value: 'uuid'}},
2729
{kind: 'Field', name: {kind: 'Name', value: 'email'}},
30+
{
31+
kind: 'Field',
32+
name: {kind: 'Name', value: 'organizations'},
33+
arguments: [
34+
{kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '5'}},
35+
],
36+
selectionSet: {
37+
kind: 'SelectionSet',
38+
selections: [
39+
{
40+
kind: 'Field',
41+
name: {kind: 'Name', value: 'nodes'},
42+
selectionSet: {
43+
kind: 'SelectionSet',
44+
selections: [
45+
{kind: 'Field', name: {kind: 'Name', value: 'name'}},
46+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
47+
],
48+
},
49+
},
50+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
51+
],
52+
},
53+
},
2854
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
2955
],
3056
},

Diff for: packages/app/src/cli/api/graphql/business-platform-destinations/queries/user-info.graphql

+5
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@ query UserInfo {
22
currentUserAccount {
33
uuid
44
email
5+
organizations(first: 2){
6+
nodes {
7+
name
8+
}
9+
}
510
}
611
}

Diff for: packages/app/src/cli/utilities/developer-platform-client.ts

+2-9
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ import {
6060
AppLogsSubscribeMutation,
6161
AppLogsSubscribeMutationVariables,
6262
} from '../api/graphql/app-management/generated/app-logs-subscribe.js'
63-
import {isAppManagementDisabled} from '@shopify/cli-kit/node/context/local'
6463
import {blockPartnersAccess} from '@shopify/cli-kit/node/environment'
65-
import {AbortError} from '@shopify/cli-kit/node/error'
6664

6765
export enum ClientName {
6866
AppManagement = 'app-management',
@@ -88,12 +86,8 @@ export function allDeveloperPlatformClients(): DeveloperPlatformClient[] {
8886
if (!blockPartnersAccess()) {
8987
clients.push(new PartnersClient())
9088
}
91-
if (!isAppManagementDisabled()) {
92-
clients.push(new AppManagementClient())
93-
}
94-
if (clients.length === 0) {
95-
throw new AbortError('Both Partners and App Management APIs are deactivated.')
96-
}
89+
90+
clients.push(new AppManagementClient())
9791
return clients
9892
}
9993

@@ -133,7 +127,6 @@ export function selectDeveloperPlatformClient({
133127
configuration,
134128
organization,
135129
}: SelectDeveloperPlatformClientOptions = {}): DeveloperPlatformClient {
136-
if (isAppManagementDisabled()) return new PartnersClient()
137130
if (organization) return selectDeveloperPlatformClientByOrg(organization)
138131
return selectDeveloperPlatformClientByConfig(configuration)
139132
}

Diff for: packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
AppLogsSubscribeMutation,
126126
AppLogsSubscribeMutationVariables,
127127
} from '../../api/graphql/app-management/generated/app-logs-subscribe.js'
128+
import {getPartnersToken} from '@shopify/cli-kit/node/environment'
128129
import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session'
129130
import {isUnitTest} from '@shopify/cli-kit/node/context/local'
130131
import {AbortError, BugError} from '@shopify/cli-kit/node/error'
@@ -251,7 +252,25 @@ export class AppManagementClient implements DeveloperPlatformClient {
251252
cacheExtraKey: userId,
252253
})
253254

254-
if (userInfoResult.currentUserAccount) {
255+
if (getPartnersToken() && userInfoResult.currentUserAccount) {
256+
const organizations = userInfoResult.currentUserAccount.organizations.nodes.map((org) => ({
257+
name: org.name,
258+
}))
259+
260+
if (organizations.length > 1) {
261+
throw new Error('Multiple organizations found for the CLI token')
262+
}
263+
264+
this._session = {
265+
token: appManagementToken,
266+
businessPlatformToken,
267+
accountInfo: {
268+
type: 'ServiceAccount',
269+
orgName: organizations[0]?.name || '',
270+
},
271+
userId,
272+
}
273+
} else if (userInfoResult.currentUserAccount) {
255274
this._session = {
256275
token: appManagementToken,
257276
businessPlatformToken,

Diff for: packages/cli-kit/src/private/node/session/exchange.ts

+36-8
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {identityFqdn} from '../../../public/node/context/fqdn.js'
55
import {shopifyFetch} from '../../../public/node/http.js'
66
import {err, ok, Result} from '../../../public/node/result.js'
77
import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js'
8-
import {isAppManagementDisabled} from '../../../public/node/context/local.js'
98
import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js'
109
import * as jose from 'jose'
1110
import {nonRandomUUID} from '@shopify/cli-kit/node/crypto'
@@ -40,7 +39,7 @@ export async function exchangeAccessForApplicationTokens(
4039
requestAppToken('storefront-renderer', token, scopes.storefront),
4140
requestAppToken('business-platform', token, scopes.businessPlatform),
4241
store ? requestAppToken('admin', token, scopes.admin, store) : {},
43-
isAppManagementDisabled() ? {} : requestAppToken('app-management', token, scopes.appManagement),
42+
requestAppToken('app-management', token, scopes.appManagement),
4443
])
4544

4645
return {
@@ -69,26 +68,55 @@ export async function refreshAccessToken(currentToken: IdentityToken): Promise<I
6968
}
7069

7170
/**
72-
* Given a custom CLI token passed as ENV variable, request a valid partners API token
73-
* This token does not accept extra scopes, just the cli one.
71+
* Given a custom CLI token passed as ENV variable (`SHOPIFY_CLI_PARTNERS_TOKEN`), request a valid API access token
7472
* @param token - The CLI token passed as ENV variable
7573
* @returns An instance with the application access tokens.
7674
*/
77-
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
78-
const appId = applicationId('partners')
75+
export async function exchangeCliTokenForAccessToken(
76+
apiName: API,
77+
token: string,
78+
scopes: string[],
79+
): Promise<{accessToken: string; userId: string}> {
80+
const appId = applicationId(apiName)
7981
try {
80-
const newToken = await requestAppToken('partners', token, ['https://api.shopify.com/auth/partners.app.cli.access'])
82+
const newToken = await requestAppToken(apiName, token, scopes)
8183
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
8284
const accessToken = newToken[appId]!.accessToken
8385
const userId = nonRandomUUID(token)
8486
setLastSeenUserIdAfterAuth(userId)
8587
setLastSeenAuthMethod('partners_token')
8688
return {accessToken, userId}
8789
} catch (error) {
88-
throw new AbortError('The custom token provided is invalid.', 'Ensure the token is correct and not expired.')
90+
const prettyName = apiName.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
91+
92+
throw new AbortError(
93+
`The custom token provided can't be used for the ${prettyName} API.`,
94+
'Ensure the token is correct and not expired.',
95+
)
8996
}
9097
}
9198

99+
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
100+
return exchangeCliTokenForAccessToken('partners', token, ['https://api.shopify.com/auth/partners.app.cli.access'])
101+
}
102+
103+
export async function exchangeCliTokenForAppManagementAccessToken(
104+
token: string,
105+
): Promise<{accessToken: string; userId: string}> {
106+
return exchangeCliTokenForAccessToken('app-management', token, [
107+
'https://api.shopify.com/auth/organization.apps.manage',
108+
])
109+
}
110+
111+
export async function exchangeCliTokenForBusinessPlatformAccessToken(
112+
token: string,
113+
): Promise<{accessToken: string; userId: string}> {
114+
return exchangeCliTokenForAccessToken('business-platform', token, [
115+
'https://api.shopify.com/auth/destinations.readonly',
116+
'https://api.shopify.com/auth/organization.store-management',
117+
])
118+
}
119+
92120
type IdentityDeviceError = 'authorization_pending' | 'access_denied' | 'expired_token' | 'slow_down' | 'unknown_failure'
93121

94122
/**

Diff for: packages/cli-kit/src/public/node/context/local.test.ts

-26
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ import {
77
analyticsDisabled,
88
cloudEnvironment,
99
macAddress,
10-
isAppManagementDisabled,
1110
getThemeKitAccessDomain,
1211
opentelemetryDomain,
1312
} from './local.js'
14-
import {getPartnersToken} from '../environment.js'
1513
import {fileExists} from '../fs.js'
1614
import {exec} from '../system.js'
1715
import {expect, describe, vi, test} from 'vitest'
@@ -104,30 +102,6 @@ describe('hasGit', () => {
104102
})
105103
})
106104

107-
describe('isAppManagementDisabled', () => {
108-
test('returns true when a Partners token is present', () => {
109-
// Given
110-
vi.mocked(getPartnersToken).mockReturnValue('token')
111-
112-
// When
113-
const got = isAppManagementDisabled()
114-
115-
// Then
116-
expect(got).toBe(true)
117-
})
118-
119-
test('returns false when a Partners token is not present', () => {
120-
// Given
121-
vi.mocked(getPartnersToken).mockReturnValue(undefined)
122-
123-
// When
124-
const got = isAppManagementDisabled()
125-
126-
// Then
127-
expect(got).toBe(false)
128-
})
129-
})
130-
131105
describe('analitycsDisabled', () => {
132106
test('returns true when SHOPIFY_CLI_NO_ANALYTICS is truthy', () => {
133107
// Given

Diff for: packages/cli-kit/src/public/node/context/local.ts

-11
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {getCIMetadata, isSet, Metadata} from '../../../private/node/context/util
44
import {defaultThemeKitAccessDomain, environmentVariables, pathConstants} from '../../../private/node/constants.js'
55
import {fileExists} from '../fs.js'
66
import {exec} from '../system.js'
7-
import {getPartnersToken} from '../environment.js'
87
import isInteractive from 'is-interactive'
98
import macaddress from 'macaddress'
109
import {homedir} from 'os'
@@ -47,16 +46,6 @@ export function isVerbose(env = process.env): boolean {
4746
return isTruthy(env[environmentVariables.verbose]) || process.argv.includes('--verbose')
4847
}
4948

50-
/**
51-
* It returns true if the App Management API is disabled.
52-
* This should only be relevant when using a Partners token.
53-
*
54-
* @returns True if the App Management API is disabled.
55-
*/
56-
export function isAppManagementDisabled(): boolean {
57-
return Boolean(getPartnersToken())
58-
}
59-
6049
/**
6150
* Returns true if the environment in which the CLI is running is either
6251
* a local environment (where dev is present) or a cloud environment (spin).

Diff for: packages/cli-kit/src/public/node/session.test.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
import {getPartnersToken} from './environment.js'
1111
import {ApplicationToken} from '../../private/node/session/schema.js'
1212
import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js'
13-
import {exchangeCustomPartnerToken} from '../../private/node/session/exchange.js'
13+
import {
14+
exchangeCustomPartnerToken,
15+
exchangeCliTokenForAppManagementAccessToken,
16+
exchangeCliTokenForBusinessPlatformAccessToken,
17+
} from '../../private/node/session/exchange.js'
1418
import {vi, describe, expect, test} from 'vitest'
1519

1620
const futureDate = new Date(2022, 1, 1, 11)
@@ -242,4 +246,28 @@ describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => {
242246
// Then
243247
await expect(got).rejects.toThrow('No App Management or Business Platform token found after ensuring authenticated')
244248
})
249+
250+
test('returns app managment and business platform tokens if CLI token envvar is defined', async () => {
251+
// Given
252+
vi.mocked(getPartnersToken).mockReturnValue('custom_cli_token')
253+
vi.mocked(exchangeCliTokenForAppManagementAccessToken).mockResolvedValueOnce({
254+
accessToken: 'app-management-token',
255+
userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d',
256+
})
257+
vi.mocked(exchangeCliTokenForBusinessPlatformAccessToken).mockResolvedValueOnce({
258+
accessToken: 'business-platform-token',
259+
userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d',
260+
})
261+
262+
// When
263+
const got = await ensureAuthenticatedAppManagementAndBusinessPlatform()
264+
265+
// Then
266+
expect(got).toEqual({
267+
appManagementToken: 'app-management-token',
268+
userId: '575e2102-cb13-7bea-4631-ce3469eac491cdcba07d',
269+
businessPlatformToken: 'business-platform-token',
270+
})
271+
expect(ensureAuthenticated).not.toHaveBeenCalled()
272+
})
245273
})

Diff for: packages/cli-kit/src/public/node/session.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {BugError} from './error.js'
33
import {getPartnersToken} from './environment.js'
44
import {nonRandomUUID} from './crypto.js'
55
import * as secureStore from '../../private/node/session/store.js'
6-
import {exchangeCustomPartnerToken} from '../../private/node/session/exchange.js'
6+
import {
7+
exchangeCustomPartnerToken,
8+
exchangeCliTokenForAppManagementAccessToken,
9+
exchangeCliTokenForBusinessPlatformAccessToken,
10+
} from '../../private/node/session/exchange.js'
711
import {outputContent, outputToken, outputDebug} from '../../public/node/output.js'
812
import {
913
AdminAPIScope,
@@ -77,6 +81,19 @@ export async function ensureAuthenticatedAppManagementAndBusinessPlatform(
7781
outputDebug(outputContent`Ensuring that the user is authenticated with the App Management API with the following scopes:
7882
${outputToken.json(appManagementScopes)}
7983
`)
84+
85+
const envToken = getPartnersToken()
86+
if (envToken) {
87+
const appManagmentToken = await exchangeCliTokenForAppManagementAccessToken(envToken)
88+
const businessPlatformToken = await exchangeCliTokenForBusinessPlatformAccessToken(envToken)
89+
90+
return {
91+
appManagementToken: appManagmentToken.accessToken,
92+
userId: appManagmentToken.userId,
93+
businessPlatformToken: businessPlatformToken.accessToken,
94+
}
95+
}
96+
8097
const tokens = await ensureAuthenticated(
8198
{appManagementApi: {scopes: appManagementScopes}, businessPlatformApi: {scopes: businessPlatformScopes}},
8299
env,

0 commit comments

Comments
 (0)