Skip to content

Commit 87d5eda

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 87d5eda

File tree

10 files changed

+233
-87
lines changed

10 files changed

+233
-87
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: '2'}},
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.test.ts

+93-28
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
exchangeAccessForApplicationTokens,
33
exchangeCustomPartnerToken,
4+
exchangeCliTokenForAppManagementAccessToken,
5+
exchangeCliTokenForBusinessPlatformAccessToken,
46
InvalidGrantError,
57
InvalidRequestError,
68
refreshAccessToken,
@@ -9,7 +11,7 @@ import {applicationId, clientId} from './identity.js'
911
import {IdentityToken} from './schema.js'
1012
import {shopifyFetch} from '../../../public/node/http.js'
1113
import {identityFqdn} from '../../../public/node/context/fqdn.js'
12-
import {getLastSeenUserIdAfterAuth} from '../session.js'
14+
import {getLastSeenUserIdAfterAuth, getLastSeenAuthMethod} from '../session.js'
1315
import {describe, test, expect, vi, afterAll, beforeEach} from 'vitest'
1416
import {Response} from 'node-fetch'
1517
import {AbortError} from '@shopify/cli-kit/node/error'
@@ -197,30 +199,93 @@ describe('refresh access tokens', () => {
197199
})
198200
})
199201

200-
describe('exchangeCustomPartnerToken', () => {
201-
const token = 'customToken'
202-
203-
// Generated from `customToken` using `nonRandomUUID()`
204-
const userId = 'eab16ac4-0690-5fed-9d00-71bd202a3c2b37259a8f'
205-
206-
test('returns access token and user ID for a valid token', async () => {
207-
// Given
208-
const data = {
209-
access_token: 'access_token',
210-
expires_in: 300,
211-
scope: 'scope,scope2',
212-
}
213-
// Given
214-
const response = new Response(JSON.stringify(data))
215-
216-
// Need to do it 3 times because a Response can only be used once
217-
vi.mocked(shopifyFetch).mockResolvedValue(response)
218-
219-
// When
220-
const result = await exchangeCustomPartnerToken(token)
221-
222-
// Then
223-
expect(result).toEqual({accessToken: 'access_token', userId})
224-
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(userId)
225-
})
226-
})
202+
const tokenExchangeMethods = [
203+
{
204+
tokenExchangeMethod: exchangeCustomPartnerToken,
205+
expectedScopes: ['https://api.shopify.com/auth/partners.app.cli.access'],
206+
expectedApi: 'partners',
207+
expectedErrorName: 'Partners',
208+
},
209+
{
210+
tokenExchangeMethod: exchangeCliTokenForAppManagementAccessToken,
211+
expectedScopes: ['https://api.shopify.com/auth/organization.apps.manage'],
212+
expectedApi: 'app-management',
213+
expectedErrorName: 'App Management',
214+
},
215+
{
216+
tokenExchangeMethod: exchangeCliTokenForBusinessPlatformAccessToken,
217+
expectedScopes: [
218+
'https://api.shopify.com/auth/destinations.readonly',
219+
'https://api.shopify.com/auth/organization.store-management',
220+
],
221+
expectedApi: 'business-platform',
222+
expectedErrorName: 'Business Platform',
223+
},
224+
]
225+
226+
describe.each(tokenExchangeMethods)(
227+
'Token exchange: %s',
228+
({tokenExchangeMethod, expectedScopes, expectedApi, expectedErrorName}) => {
229+
const cliToken = 'customToken'
230+
// Generated from `customToken` using `nonRandomUUID()`
231+
const userId = 'eab16ac4-0690-5fed-9d00-71bd202a3c2b37259a8f'
232+
233+
const grantType = 'urn:ietf:params:oauth:grant-type:token-exchange'
234+
const accessTokenType = 'urn:ietf:params:oauth:token-type:access_token'
235+
236+
test(`Executing ${tokenExchangeMethod.name} returns access token and user ID for a valid CLI token`, async () => {
237+
// Given
238+
let capturedUrl = ''
239+
vi.mocked(shopifyFetch).mockImplementation(async (url, options) => {
240+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
241+
capturedUrl = url.toString()
242+
return Promise.resolve(
243+
new Response(
244+
JSON.stringify({
245+
access_token: 'expected_access_token',
246+
expires_in: 300,
247+
scope: 'scope,scope2',
248+
}),
249+
),
250+
)
251+
})
252+
253+
// When
254+
const result = await tokenExchangeMethod(cliToken)
255+
256+
// Then
257+
expect(result).toEqual({accessToken: 'expected_access_token', userId})
258+
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(userId)
259+
await expect(getLastSeenAuthMethod()).resolves.toBe('partners_token')
260+
261+
// Assert token exchange parameters are correct
262+
const actualUrl = new URL(capturedUrl)
263+
expect(actualUrl).toBeDefined()
264+
expect(actualUrl.href).toContain('https://fqdn.com/oauth/token')
265+
266+
const params = actualUrl.searchParams
267+
expect(params.get('grant_type')).toBe(grantType)
268+
expect(params.get('requested_token_type')).toBe(accessTokenType)
269+
expect(params.get('subject_token_type')).toBe(accessTokenType)
270+
expect(params.get('client_id')).toBe('clientId')
271+
expect(params.get('audience')).toBe(expectedApi)
272+
expect(params.get('scope')).toBe(expectedScopes.join(' '))
273+
expect(params.get('subject_token')).toBe(cliToken)
274+
})
275+
276+
test(`Executing ${tokenExchangeMethod.name} throws AbortError if an error is caught`, async () => {
277+
const expectedErrorMessage = `The custom token provided can't be used for the ${expectedErrorName} API.`
278+
vi.mocked(shopifyFetch).mockImplementation(async () => {
279+
throw new Error('BAD ERROR')
280+
})
281+
282+
try {
283+
await tokenExchangeMethod(cliToken)
284+
// eslint-disable-next-line no-catch-all/no-catch-all
285+
} catch (error) {
286+
expect(error).toBeInstanceOf(AbortError)
287+
expect(error.message).toBe(expectedErrorMessage)
288+
}
289+
})
290+
},
291+
)

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

+39-9
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,56 @@ 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.
74-
* @param token - The CLI token passed as ENV variable
71+
* Given a custom CLI token passed as ENV variable request a valid API access token
72+
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
73+
* @param apiName - The API to exchange for the access token
74+
* @param scopes - The scopes to request with the access token
7575
* @returns An instance with the application access tokens.
7676
*/
77-
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
78-
const appId = applicationId('partners')
77+
async function exchangeCliTokenForAccessToken(
78+
apiName: API,
79+
token: string,
80+
scopes: string[],
81+
): Promise<{accessToken: string; userId: string}> {
82+
const appId = applicationId(apiName)
7983
try {
80-
const newToken = await requestAppToken('partners', token, ['https://api.shopify.com/auth/partners.app.cli.access'])
84+
const newToken = await requestAppToken(apiName, token, scopes)
8185
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
8286
const accessToken = newToken[appId]!.accessToken
8387
const userId = nonRandomUUID(token)
8488
setLastSeenUserIdAfterAuth(userId)
8589
setLastSeenAuthMethod('partners_token')
8690
return {accessToken, userId}
8791
} catch (error) {
88-
throw new AbortError('The custom token provided is invalid.', 'Ensure the token is correct and not expired.')
92+
const prettyName = apiName.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
93+
throw new AbortError(
94+
`The custom token provided can't be used for the ${prettyName} API.`,
95+
'Ensure the token is correct and not expired.',
96+
)
8997
}
9098
}
9199

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

94123
/**
@@ -187,6 +216,7 @@ async function tokenRequest(params: {[key: string]: string}): Promise<Result<Tok
187216
const fqdn = await identityFqdn()
188217
const url = new URL(`https://${fqdn}/oauth/token`)
189218
url.search = new URLSearchParams(Object.entries(params)).toString()
219+
190220
const res = await shopifyFetch(url.href, {method: 'POST'})
191221
// eslint-disable-next-line @typescript-eslint/no-explicit-any
192222
const payload: any = await res.json()

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

0 commit comments

Comments
 (0)