Skip to content

Commit e6b204f

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 ab609e6 commit e6b204f

File tree

10 files changed

+248
-87
lines changed

10 files changed

+248
-87
lines changed

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
},

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
}

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
}

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,

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

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

+54-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,71 @@ 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+
/**
101+
* Given a custom CLI token passed as ENV variable, request a valid Partners API token
102+
* This token does not accept extra scopes, just the cli one.
103+
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
104+
* @returns An instance with the application access tokens.
105+
*/
106+
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
107+
return exchangeCliTokenForAccessToken('partners', token, ['https://api.shopify.com/auth/partners.app.cli.access'])
108+
}
109+
110+
/**
111+
* Given a custom CLI token passed as ENV variable, request a valid App Management API token
112+
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
113+
* @returns An instance with the application access tokens.
114+
*/
115+
export async function exchangeCliTokenForAppManagementAccessToken(
116+
token: string,
117+
): Promise<{accessToken: string; userId: string}> {
118+
return exchangeCliTokenForAccessToken('app-management', token, [
119+
'https://api.shopify.com/auth/organization.apps.manage',
120+
])
121+
}
122+
123+
/**
124+
* Given a custom CLI token passed as ENV variable, request a valid Business Platform API token
125+
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
126+
* @returns An instance with the application access tokens.
127+
*/
128+
export async function exchangeCliTokenForBusinessPlatformAccessToken(
129+
token: string,
130+
): Promise<{accessToken: string; userId: string}> {
131+
return exchangeCliTokenForAccessToken('business-platform', token, [
132+
'https://api.shopify.com/auth/destinations.readonly',
133+
])
134+
}
135+
92136
type IdentityDeviceError = 'authorization_pending' | 'access_denied' | 'expired_token' | 'slow_down' | 'unknown_failure'
93137

94138
/**
@@ -187,6 +231,7 @@ async function tokenRequest(params: {[key: string]: string}): Promise<Result<Tok
187231
const fqdn = await identityFqdn()
188232
const url = new URL(`https://${fqdn}/oauth/token`)
189233
url.search = new URLSearchParams(Object.entries(params)).toString()
234+
190235
const res = await shopifyFetch(url.href, {method: 'POST'})
191236
// eslint-disable-next-line @typescript-eslint/no-explicit-any
192237
const payload: any = await res.json()

0 commit comments

Comments
 (0)