Skip to content
Draft
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
57 changes: 57 additions & 0 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,46 @@ describe('when existing session is valid', () => {
expect(fetchSessions).toHaveBeenCalledOnce()
})

test('returns identity token when identity auth is requested', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('ok')
vi.mocked(fetchSessions).mockResolvedValue(validSessions)
const identityScope = 'https://api.shopify.com/auth/example.scope'

// When
const got = await ensureAuthenticated({
identityApi: {scopes: [identityScope]},
})

// Then
expect(got).toEqual({identity: 'access_token', userId})
expect(validateSession).toHaveBeenCalledWith(
[identityScope],
{identityApi: {scopes: [identityScope]}},
validSessions[fqdn]![userId],
)
})

test('does not exchange application tokens for identity-only auth', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
vi.mocked(fetchSessions).mockResolvedValue(undefined)
const identityScope = 'https://api.shopify.com/auth/example.scope'

// When
const got = await ensureAuthenticated({
identityApi: {scopes: [identityScope]},
})

// Then
expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled()
expect(businessPlatformRequest).not.toHaveBeenCalled()
expect(got).toEqual({identity: 'access_token', userId})

const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.applications).toEqual({})
})

test('overwrites partners token if provided with a custom CLI token', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('ok')
Expand Down Expand Up @@ -371,6 +411,23 @@ describe('when existing session is expired', () => {
expect(fetchSessions).toHaveBeenCalledOnce()
})

test('does not exchange application tokens when refreshing identity-only auth', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
vi.mocked(fetchSessions).mockResolvedValue(validSessions)
const identityScope = 'https://api.shopify.com/auth/example.scope'

// When
const got = await ensureAuthenticated({
identityApi: {scopes: [identityScope]},
})

// Then
expect(refreshAccessToken).toHaveBeenCalled()
expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled()
expect(got).toEqual({identity: 'access_token', userId})
})

test('attempts to refresh the token and executes a complete flow if identity returns an invalid grant error', async () => {
// Given
const tokenResponseError = new InvalidGrantError()
Expand Down
51 changes: 38 additions & 13 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
InvalidGrantError,
InvalidRequestError,
} from './session/exchange.js'
import {IdentityToken, Session, Sessions} from './session/schema.js'
import {ApplicationToken, IdentityToken, Session, Sessions} from './session/schema.js'
import * as sessionStore from './session/store.js'
import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js'
import {isThemeAccessSession} from './api/rest.js'
Expand Down Expand Up @@ -92,6 +92,12 @@ interface BusinessPlatformAPIOAuthOptions {
scopes: BusinessPlatformScope[]
}

export type IdentityScope = string
interface IdentityOAuthOptions {
/** List of Identity scopes to request permissions for. */
scopes: IdentityScope[]
}

/**
* It represents the authentication requirements and
* is the input necessary to trigger the authentication
Expand All @@ -103,6 +109,7 @@ export interface OAuthApplications {
partnersApi?: PartnersAPIOAuthOptions
businessPlatformApi?: BusinessPlatformAPIOAuthOptions
appManagementApi?: AppManagementAPIOauthOptions
identityApi?: IdentityOAuthOptions
}

export interface OAuthSession {
Expand All @@ -111,6 +118,7 @@ export interface OAuthSession {
storefront?: string
businessPlatform?: string
appManagement?: string
identity?: string
userId: string
}

Expand Down Expand Up @@ -294,7 +302,6 @@ The CLI is currently unable to prompt for reauthentication.`,
*/
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
const scopes = getFlattenScopes(applications)
const exchangeScopes = getExchangeScopes(applications)
const store = applications.adminApi?.storeFqdn
if (firstPartyDev()) {
outputDebug(outputContent`Authenticating as Shopify Employee...`)
Expand All @@ -315,9 +322,12 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
}

// Exchange identity token for application tokens
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
let result: Record<string, ApplicationToken> = {}
if (requiresApplicationTokenExchange(applications)) {
// Exchange identity token for application tokens
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
result = await exchangeAccessForApplicationTokens(identityToken, getExchangeScopes(applications), store)
}

// Preserve existing alias if available, otherwise try fetching email
const businessPlatformToken = result[applicationId('business-platform')]?.accessToken
Expand All @@ -344,20 +354,30 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
async function refreshTokens(session: Session, applications: OAuthApplications): Promise<Session> {
// Refresh Identity Token
const identityToken = await refreshAccessToken(session.identity)
// Exchange new identity token for application tokens
const exchangeScopes = getExchangeScopes(applications)
const applicationTokens = await exchangeAccessForApplicationTokens(
identityToken,
exchangeScopes,
applications.adminApi?.storeFqdn,
)
const applicationTokens = requiresApplicationTokenExchange(applications)
? await exchangeAccessForApplicationTokens(
identityToken,
getExchangeScopes(applications),
applications.adminApi?.storeFqdn,
)
: {}

return {
identity: {...identityToken, alias: session.identity.alias},
applications: applicationTokens,
}
}

function requiresApplicationTokenExchange(apps: OAuthApplications): boolean {
return [
apps.adminApi,
apps.storefrontRendererApi,
apps.partnersApi,
apps.businessPlatformApi,
apps.appManagementApi,
].some((app) => app !== undefined)
}

/**
* Get the application tokens for a given session.
*
Expand Down Expand Up @@ -399,6 +419,10 @@ async function tokensFor(applications: OAuthApplications, session: Session): Pro
tokens.appManagement = session.applications[appId]?.accessToken
}

if (applications.identityApi) {
tokens.identity = session.identity.accessToken
}

return tokens
}

Expand All @@ -415,7 +439,8 @@ function getFlattenScopes(apps: OAuthApplications): string[] {
const storefront = apps.storefrontRendererApi?.scopes ?? []
const businessPlatform = apps.businessPlatformApi?.scopes ?? []
const appManagement = apps.appManagementApi?.scopes ?? []
const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement]
const identity = apps.identityApi?.scopes ?? []
const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement, ...identity]
return allDefaultScopes(requestedScopes)
}

Expand Down
24 changes: 24 additions & 0 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ensureAuthenticatedAdminAsApp,
ensureAuthenticatedAppManagementAndBusinessPlatform,
ensureAuthenticatedBusinessPlatform,
ensureAuthenticatedIdentity,
ensureAuthenticatedPartners,
ensureAuthenticatedStorefront,
ensureAuthenticatedThemes,
Expand Down Expand Up @@ -42,6 +43,29 @@ describe('store command analytics session helpers', () => {
})
})

describe('ensureAuthenticatedIdentity', () => {
test('returns the identity token when success', async () => {
vi.mocked(ensureAuthenticated).mockResolvedValueOnce({identity: 'identity_token', userId: '1234-5678'})

const got = await ensureAuthenticatedIdentity(['https://api.shopify.com/auth/example.scope'])

expect(got).toEqual({token: 'identity_token', userId: '1234-5678'})
expect(ensureAuthenticated).toHaveBeenCalledWith(
{identityApi: {scopes: ['https://api.shopify.com/auth/example.scope']}},
process.env,
{},
)
})

test('throws error if there is no identity token', async () => {
vi.mocked(ensureAuthenticated).mockResolvedValueOnce({userId: '1234-5678'})

const got = ensureAuthenticatedIdentity(['https://api.shopify.com/auth/example.scope'])

await expect(got).rejects.toThrow(`No identity token`)
})
})

describe('ensureAuthenticatedStorefront', () => {
test('returns only storefront token if success', async () => {
// Given
Expand Down
27 changes: 27 additions & 0 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
AppManagementAPIScope,
BusinessPlatformScope,
EnsureAuthenticatedAdditionalOptions,
IdentityScope,
PartnersAPIScope,
StorefrontRendererScope,
ensureAuthenticated,
Expand Down Expand Up @@ -101,6 +102,32 @@ export async function ensureAuthenticatedUser(
return {userId: tokens.userId}
}

/**
* Ensure that we have a valid Identity access token with the requested scopes.
*
* Use this when a first-party Shopify service validates the CLI caller directly
* with Identity rather than with an exchanged application token.
*
* @param scopes - Identity scopes to authenticate with.
* @param env - Optional environment variables to use.
* @param options - Optional extra options to use.
* @returns The Identity access token and user ID.
*/
export async function ensureAuthenticatedIdentity(
scopes: IdentityScope[] = [],
env = process.env,
options: EnsureAuthenticatedAdditionalOptions = {},
): Promise<{token: string; userId: string}> {
outputDebug(outputContent`Ensuring that the user is authenticated with Identity scopes:
${outputToken.json(scopes)}
`)
const tokens = await ensureAuthenticated({identityApi: {scopes}}, env, options)
if (!tokens.identity) {
throw new BugError('No identity token found after ensuring authenticated')
}
return {token: tokens.identity, userId: tokens.userId}
}

/**
* Ensure that we have a valid session to access the Partners API.
* If SHOPIFY_CLI_PARTNERS_TOKEN exists, that token will be used to obtain a valid Partners Token
Expand Down
91 changes: 90 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3659,6 +3659,95 @@
"pluginType": "core",
"strict": true
},
"flow:tool:call": {
"aliases": [
],
"args": {
"tool": {
"description": "The Flow tool name to call.",
"name": "tool",
"required": true
}
},
"customPluginName": "@shopify/store",
"description": "Calls a Shopify Flow tool for the specified store.\n\nThe CLI owns authentication and backend routing. Agents and scripts should use this command rather than calling Flow or Sidekick endpoints directly.",
"descriptionWithMarkdown": "Calls a Shopify Flow tool for the specified store.\n\nThe CLI owns authentication and backend routing. Agents and scripts should use this command rather than calling Flow or Sidekick endpoints directly.",
"examples": [
"<%= config.bin %> <%= command.id %> flow_app_agent_template_search --store shop.myshopify.com --arguments '{\"search_queries\":[\"fraud prevention\"]}' --json",
"<%= config.bin %> <%= command.id %> flow_app_agent_create_or_update_workflow_from_json --store shop.myshopify.com --arguments-file ./workflow.json --json"
],
"flags": {
"arguments": {
"description": "The JSON object arguments for the tool.",
"env": "SHOPIFY_FLAG_FLOW_TOOL_ARGUMENTS",
"hasDynamicHelp": false,
"multiple": false,
"name": "arguments",
"type": "option"
},
"arguments-file": {
"description": "Path to a file containing the tool arguments as JSON. Can't be used with --arguments.",
"env": "SHOPIFY_FLAG_FLOW_TOOL_ARGUMENTS_FILE",
"hasDynamicHelp": false,
"multiple": false,
"name": "arguments-file",
"type": "option"
},
"endpoint": {
"description": "Override the Flow tool gateway endpoint. Intended for local development.",
"env": "SHOPIFY_FLOW_TOOL_CALL_ENDPOINT",
"hasDynamicHelp": false,
"hidden": true,
"multiple": false,
"name": "endpoint",
"type": "option"
},
"json": {
"allowNo": false,
"char": "j",
"description": "Output the result as JSON. Automatically disables color output.",
"env": "SHOPIFY_FLAG_JSON",
"hidden": false,
"name": "json",
"type": "boolean"
},
"no-color": {
"allowNo": false,
"description": "Disable color output.",
"env": "SHOPIFY_FLAG_NO_COLOR",
"hidden": false,
"name": "no-color",
"type": "boolean"
},
"store": {
"char": "s",
"description": "The myshopify.com domain of the store to execute against.",
"env": "SHOPIFY_FLAG_STORE",
"hasDynamicHelp": false,
"multiple": false,
"name": "store",
"required": true,
"type": "option"
},
"verbose": {
"allowNo": false,
"description": "Increase the verbosity of the output.",
"env": "SHOPIFY_FLAG_VERBOSE",
"hidden": false,
"name": "verbose",
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [
],
"id": "flow:tool:call",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
"summary": "Call a Shopify Flow tool."
},
"help": {
"aliases": [
],
Expand Down Expand Up @@ -8461,4 +8550,4 @@
}
},
"version": "3.94.0"
}
}
Loading
Loading