Skip to content

Add CLI token support for app management and BP API #5604

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
6 changes: 6 additions & 0 deletions .changeset/tasty-terms-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': minor
'@shopify/app': minor
---

Add support to use App Management API with CLI Tokens.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co

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

export type UserInfoQuery = {currentUserAccount?: {uuid: string; email: string} | null}
export type UserInfoQuery = {
currentUserAccount?: {uuid: string; email: string; organizations: {nodes: {name: string}[]}} | null
}

export const UserInfo = {
kind: 'Document',
Expand All @@ -25,6 +27,30 @@ export const UserInfo = {
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'uuid'}},
{kind: 'Field', name: {kind: 'Name', value: 'email'}},
{
kind: 'Field',
name: {kind: 'Name', value: 'organizations'},
arguments: [
{kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '2'}},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'nodes'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'name'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@ query UserInfo {
currentUserAccount {
uuid
email
organizations(first: 2){
nodes {
name
}
}
}
}
11 changes: 2 additions & 9 deletions packages/app/src/cli/utilities/developer-platform-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ import {
AppLogsSubscribeMutation,
AppLogsSubscribeMutationVariables,
} from '../api/graphql/app-management/generated/app-logs-subscribe.js'
import {isAppManagementDisabled} from '@shopify/cli-kit/node/context/local'
import {blockPartnersAccess} from '@shopify/cli-kit/node/environment'
import {AbortError} from '@shopify/cli-kit/node/error'

export enum ClientName {
AppManagement = 'app-management',
Expand All @@ -88,12 +86,8 @@ export function allDeveloperPlatformClients(): DeveloperPlatformClient[] {
if (!blockPartnersAccess()) {
clients.push(new PartnersClient())
}
if (!isAppManagementDisabled()) {
clients.push(new AppManagementClient())
}
if (clients.length === 0) {
throw new AbortError('Both Partners and App Management APIs are deactivated.')
}

clients.push(new AppManagementClient())
return clients
}

Expand Down Expand Up @@ -133,7 +127,6 @@ export function selectDeveloperPlatformClient({
configuration,
organization,
}: SelectDeveloperPlatformClientOptions = {}): DeveloperPlatformClient {
if (isAppManagementDisabled()) return new PartnersClient()
if (organization) return selectDeveloperPlatformClientByOrg(organization)
return selectDeveloperPlatformClientByConfig(configuration)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import {
AppLogsSubscribeMutation,
AppLogsSubscribeMutationVariables,
} from '../../api/graphql/app-management/generated/app-logs-subscribe.js'
import {getPartnersToken} from '@shopify/cli-kit/node/environment'
import {ensureAuthenticatedAppManagementAndBusinessPlatform} 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 @@ -251,7 +252,25 @@ export class AppManagementClient implements DeveloperPlatformClient {
cacheExtraKey: userId,
})

if (userInfoResult.currentUserAccount) {
if (getPartnersToken() && userInfoResult.currentUserAccount) {
const organizations = userInfoResult.currentUserAccount.organizations.nodes.map((org) => ({
name: org.name,
}))

if (organizations.length > 1) {
throw new BugError('Multiple organizations found for the CLI token')
}

this._session = {
token: appManagementToken,
businessPlatformToken,
accountInfo: {
type: 'ServiceAccount',
orgName: organizations[0]?.name ?? 'Unknown organization',
},
userId,
}
} else if (userInfoResult.currentUserAccount) {
this._session = {
token: appManagementToken,
businessPlatformToken,
Expand Down
121 changes: 93 additions & 28 deletions packages/cli-kit/src/private/node/session/exchange.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
exchangeAccessForApplicationTokens,
exchangeCustomPartnerToken,
exchangeCliTokenForAppManagementAccessToken,
exchangeCliTokenForBusinessPlatformAccessToken,
InvalidGrantError,
InvalidRequestError,
refreshAccessToken,
Expand All @@ -9,7 +11,7 @@ import {applicationId, clientId} from './identity.js'
import {IdentityToken} from './schema.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {getLastSeenUserIdAfterAuth} from '../session.js'
import {getLastSeenUserIdAfterAuth, getLastSeenAuthMethod} from '../session.js'
import {describe, test, expect, vi, afterAll, beforeEach} from 'vitest'
import {Response} from 'node-fetch'
import {AbortError} from '@shopify/cli-kit/node/error'
Expand Down Expand Up @@ -197,30 +199,93 @@ describe('refresh access tokens', () => {
})
})

describe('exchangeCustomPartnerToken', () => {
const token = 'customToken'

// Generated from `customToken` using `nonRandomUUID()`
const userId = 'eab16ac4-0690-5fed-9d00-71bd202a3c2b37259a8f'

test('returns access token and user ID for a valid token', async () => {
// Given
const data = {
access_token: 'access_token',
expires_in: 300,
scope: 'scope,scope2',
}
// Given
const response = new Response(JSON.stringify(data))

// Need to do it 3 times because a Response can only be used once
vi.mocked(shopifyFetch).mockResolvedValue(response)

// When
const result = await exchangeCustomPartnerToken(token)

// Then
expect(result).toEqual({accessToken: 'access_token', userId})
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(userId)
})
})
const tokenExchangeMethods = [
{
tokenExchangeMethod: exchangeCustomPartnerToken,
expectedScopes: ['https://api.shopify.com/auth/partners.app.cli.access'],
expectedApi: 'partners',
expectedErrorName: 'Partners',
},
{
tokenExchangeMethod: exchangeCliTokenForAppManagementAccessToken,
expectedScopes: ['https://api.shopify.com/auth/organization.apps.manage'],
expectedApi: 'app-management',
expectedErrorName: 'App Management',
},
{
tokenExchangeMethod: exchangeCliTokenForBusinessPlatformAccessToken,
expectedScopes: ['https://api.shopify.com/auth/destinations.readonly'],
expectedApi: 'business-platform',
expectedErrorName: 'Business Platform',
},
]

describe.each(tokenExchangeMethods)(
'Token exchange: %s',
({tokenExchangeMethod, expectedScopes, expectedApi, expectedErrorName}) => {
const cliToken = 'customToken'
// Generated from `customToken` using `nonRandomUUID()`
const userId = 'eab16ac4-0690-5fed-9d00-71bd202a3c2b37259a8f'

const grantType = 'urn:ietf:params:oauth:grant-type:token-exchange'
const accessTokenType = 'urn:ietf:params:oauth:token-type:access_token'

test(`Executing ${tokenExchangeMethod.name} returns access token and user ID for a valid CLI token`, async () => {
// Given
let capturedUrl = ''
vi.mocked(shopifyFetch).mockImplementation(async (url, options) => {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
capturedUrl = url.toString()
return Promise.resolve(
new Response(
JSON.stringify({
access_token: 'expected_access_token',
expires_in: 300,
scope: 'scope,scope2',
}),
),
)
})

// When
const result = await tokenExchangeMethod(cliToken)

// Then
expect(result).toEqual({accessToken: 'expected_access_token', userId})
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(userId)
await expect(getLastSeenAuthMethod()).resolves.toBe('partners_token')

// Assert token exchange parameters are correct
const actualUrl = new URL(capturedUrl)
expect(actualUrl).toBeDefined()
expect(actualUrl.href).toContain('https://fqdn.com/oauth/token')

const params = actualUrl.searchParams
expect(params.get('grant_type')).toBe(grantType)
expect(params.get('requested_token_type')).toBe(accessTokenType)
expect(params.get('subject_token_type')).toBe(accessTokenType)
expect(params.get('client_id')).toBe('clientId')
expect(params.get('audience')).toBe(expectedApi)
expect(params.get('scope')).toBe(expectedScopes.join(' '))
expect(params.get('subject_token')).toBe(cliToken)
})

test(`Executing ${tokenExchangeMethod.name} throws AbortError if an error is caught`, async () => {
const expectedErrorMessage = `The custom token provided can't be used for the ${expectedErrorName} API.`
vi.mocked(shopifyFetch).mockImplementation(async () => {
throw new Error('BAD ERROR')
})

try {
await tokenExchangeMethod(cliToken)
} catch (error) {
if (error instanceof Error) {
expect(error).toBeInstanceOf(AbortError)
expect(error.message).toBe(expectedErrorMessage)
} else {
throw error
}
}
})
},
)
60 changes: 51 additions & 9 deletions packages/cli-kit/src/private/node/session/exchange.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {ApplicationToken, IdentityToken} from './schema.js'
import {applicationId, clientId as getIdentityClientId} from './identity.js'
import {tokenExchangeScopes} from './scopes.js'
import {API} from '../api.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {err, ok, Result} from '../../../public/node/result.js'
import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js'
import {isAppManagementDisabled} from '../../../public/node/context/local.js'
import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js'
import * as jose from 'jose'
import {nonRandomUUID} from '@shopify/cli-kit/node/crypto'
Expand Down Expand Up @@ -40,7 +40,7 @@ export async function exchangeAccessForApplicationTokens(
requestAppToken('storefront-renderer', token, scopes.storefront),
requestAppToken('business-platform', token, scopes.businessPlatform),
store ? requestAppToken('admin', token, scopes.admin, store) : {},
isAppManagementDisabled() ? {} : requestAppToken('app-management', token, scopes.appManagement),
requestAppToken('app-management', token, scopes.appManagement),
])

return {
Expand Down Expand Up @@ -69,26 +69,67 @@ export async function refreshAccessToken(currentToken: IdentityToken): Promise<I
}

/**
* Given a custom CLI token passed as ENV variable, request a valid partners API token
* This token does not accept extra scopes, just the cli one.
* @param token - The CLI token passed as ENV variable
* Given a custom CLI token passed as ENV variable request a valid API access token
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
* @param apiName - The API to exchange for the access token
* @param scopes - The scopes to request with the access token
* @returns An instance with the application access tokens.
*/
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
const appId = applicationId('partners')
async function exchangeCliTokenForAccessToken(
apiName: API,
token: string,
scopes: string[],
): Promise<{accessToken: string; userId: string}> {
const appId = applicationId(apiName)
try {
const newToken = await requestAppToken('partners', token, ['https://api.shopify.com/auth/partners.app.cli.access'])
const newToken = await requestAppToken(apiName, token, scopes)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const accessToken = newToken[appId]!.accessToken
const userId = nonRandomUUID(token)
setLastSeenUserIdAfterAuth(userId)
setLastSeenAuthMethod('partners_token')
return {accessToken, userId}
} catch (error) {
throw new AbortError('The custom token provided is invalid.', 'Ensure the token is correct and not expired.')
const prettyName = apiName.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
throw new AbortError(
`The custom token provided can't be used for the ${prettyName} API.`,
'Ensure the token is correct and not expired.',
)
}
}

/**
* Given a custom CLI token passed as ENV variable, request a valid Partners API token
* This token does not accept extra scopes, just the cli one.
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
* @returns An instance with the application access tokens.
*/
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
return exchangeCliTokenForAccessToken('partners', token, tokenExchangeScopes('partners'))
}

/**
* Given a custom CLI token passed as ENV variable, request a valid App Management API token
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
* @returns An instance with the application access tokens.
*/
export async function exchangeCliTokenForAppManagementAccessToken(
token: string,
): Promise<{accessToken: string; userId: string}> {
return exchangeCliTokenForAccessToken('app-management', token, tokenExchangeScopes('app-management'))
}

/**
* Given a custom CLI token passed as ENV variable, request a valid Business Platform API token
* @param token - The CLI token passed as ENV variable `SHOPIFY_CLI_PARTNERS_TOKEN`
* @returns An instance with the application access tokens.
*/
export async function exchangeCliTokenForBusinessPlatformAccessToken(
token: string,
): Promise<{accessToken: string; userId: string}> {
return exchangeCliTokenForAccessToken('business-platform', token, tokenExchangeScopes('business-platform'))
}

type IdentityDeviceError = 'authorization_pending' | 'access_denied' | 'expired_token' | 'slow_down' | 'unknown_failure'

/**
Expand Down Expand Up @@ -187,6 +228,7 @@ async function tokenRequest(params: {[key: string]: string}): Promise<Result<Tok
const fqdn = await identityFqdn()
const url = new URL(`https://${fqdn}/oauth/token`)
url.search = new URLSearchParams(Object.entries(params)).toString()

const res = await shopifyFetch(url.href, {method: 'POST'})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload: any = await res.json()
Expand Down
Loading
Loading