Skip to content

Commit 03d7671

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 dc440ad commit 03d7671

File tree

13 files changed

+304
-88
lines changed

13 files changed

+304
-88
lines changed

Diff for: .changeset/tasty-terms-brake.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/cli-kit': minor
3+
'@shopify/app': minor
4+
---
5+
6+
Add support to use App Management API with CLI Tokens.

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 BugError('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: ['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+
)

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

+51-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {ApplicationToken, IdentityToken} from './schema.js'
22
import {applicationId, clientId as getIdentityClientId} from './identity.js'
3+
import {tokenExchangeScopes} from './scopes.js'
34
import {API} from '../api.js'
45
import {identityFqdn} from '../../../public/node/context/fqdn.js'
56
import {shopifyFetch} from '../../../public/node/http.js'
67
import {err, ok, Result} from '../../../public/node/result.js'
78
import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js'
8-
import {isAppManagementDisabled} from '../../../public/node/context/local.js'
99
import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js'
1010
import * as jose from 'jose'
1111
import {nonRandomUUID} from '@shopify/cli-kit/node/crypto'
@@ -40,7 +40,7 @@ export async function exchangeAccessForApplicationTokens(
4040
requestAppToken('storefront-renderer', token, scopes.storefront),
4141
requestAppToken('business-platform', token, scopes.businessPlatform),
4242
store ? requestAppToken('admin', token, scopes.admin, store) : {},
43-
isAppManagementDisabled() ? {} : requestAppToken('app-management', token, scopes.appManagement),
43+
requestAppToken('app-management', token, scopes.appManagement),
4444
])
4545

4646
return {
@@ -69,26 +69,67 @@ export async function refreshAccessToken(currentToken: IdentityToken): Promise<I
6969
}
7070

7171
/**
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
72+
* Given a custom CLI token passed as ENV variable request a valid API access token
73+
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
74+
* @param apiName - The API to exchange for the access token
75+
* @param scopes - The scopes to request with the access token
7576
* @returns An instance with the application access tokens.
7677
*/
77-
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
78-
const appId = applicationId('partners')
78+
async function exchangeCliTokenForAccessToken(
79+
apiName: API,
80+
token: string,
81+
scopes: string[],
82+
): Promise<{accessToken: string; userId: string}> {
83+
const appId = applicationId(apiName)
7984
try {
80-
const newToken = await requestAppToken('partners', token, ['https://api.shopify.com/auth/partners.app.cli.access'])
85+
const newToken = await requestAppToken(apiName, token, scopes)
8186
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
8287
const accessToken = newToken[appId]!.accessToken
8388
const userId = nonRandomUUID(token)
8489
setLastSeenUserIdAfterAuth(userId)
8590
setLastSeenAuthMethod('partners_token')
8691
return {accessToken, userId}
8792
} catch (error) {
88-
throw new AbortError('The custom token provided is invalid.', 'Ensure the token is correct and not expired.')
93+
const prettyName = apiName.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
94+
throw new AbortError(
95+
`The custom token provided can't be used for the ${prettyName} API.`,
96+
'Ensure the token is correct and not expired.',
97+
)
8998
}
9099
}
91100

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

94135
/**
@@ -187,6 +228,7 @@ async function tokenRequest(params: {[key: string]: string}): Promise<Result<Tok
187228
const fqdn = await identityFqdn()
188229
const url = new URL(`https://${fqdn}/oauth/token`)
189230
url.search = new URLSearchParams(Object.entries(params)).toString()
231+
190232
const res = await shopifyFetch(url.href, {method: 'POST'})
191233
// eslint-disable-next-line @typescript-eslint/no-explicit-any
192234
const payload: any = await res.json()

0 commit comments

Comments
 (0)