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
2 changes: 2 additions & 0 deletions docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -4190,6 +4190,7 @@
"name": "--scopes <value>",
"value": "string",
"description": "Comma-separated Admin API scopes to request for the app.",
"isOptional": false,
"environmentValue": "SHOPIFY_FLAG_SCOPES"
},
{
Expand All @@ -4216,6 +4217,7 @@
"name": "-s, --store <value>",
"value": "string",
"description": "The myshopify.com domain of the store to authenticate against.",
"isOptional": false,
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
Expand Down
19 changes: 13 additions & 6 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2160,12 +2160,13 @@ USAGE
$ shopify store auth --scopes <value> -s <value> [-j] [--no-color] [--verbose]

FLAGS
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
-s, --store=<value> (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate
against.
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
--scopes=<value> (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app.
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
-s, --store=<value> (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate
against.
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
--scopes=<value> (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the
app.
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.

DESCRIPTION
Authenticate an app against a store for store commands.
Expand All @@ -2175,6 +2176,12 @@ DESCRIPTION

Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.

In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete.
Agents should keep the command running until the browser authorization finishes.

In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable
session exists, it starts the same OAuth flow and waits for authentication to complete.

EXAMPLES
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5730,8 +5730,8 @@
"args": {
},
"customPluginName": "@shopify/store",
"description": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"description": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.\n\nIn an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.\n\nIn a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts the same OAuth flow and waits for authentication to complete.",
"descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.\n\nIn an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.\n\nIn a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts the same OAuth flow and waits for authentication to complete.",
"examples": [
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json"
Expand Down
4 changes: 4 additions & 0 deletions packages/store/src/cli/commands/store/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ describe('store auth command', () => {
expect(StoreAuth.flags.store).toBeDefined()
expect(StoreAuth.flags.scopes).toBeDefined()
expect(StoreAuth.flags.json).toBeDefined()
expect(StoreAuth.flags.store.required).toBe(true)
expect(StoreAuth.flags.scopes.required).toBe(true)
expect('resume' in StoreAuth.flags).toBe(false)
expect('callback-url' in StoreAuth.flags).toBe(false)
expect('port' in StoreAuth.flags).toBe(false)
expect('client-secret-file' in StoreAuth.flags).toBe(false)
})
Expand Down
9 changes: 7 additions & 2 deletions packages/store/src/cli/commands/store/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ export default class StoreAuth extends StoreCommand {

static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.

Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.

In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.

In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts the same OAuth flow and waits for authentication to complete.`

static description = this.descriptionWithoutMarkdown()

Expand Down Expand Up @@ -38,14 +42,15 @@ Re-run this command if the stored token is missing, expires, or no longer has th

public async run(): Promise<void> {
const {flags} = await this.parse(StoreAuth)
const presenter = createStoreAuthPresenter(flags.json ? 'json' : 'text')

await authenticateStoreWithApp(
{
store: flags.store,
scopes: flags.scopes,
},
{
presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'),
presenter,
},
)
}
Expand Down
102 changes: 99 additions & 3 deletions packages/store/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import {authenticateStoreWithApp} from './index.js'
import {setStoredStoreAppSession} from './session-store.js'
import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js'
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {describe, expect, test, vi} from 'vitest'
import {randomUUID} from '@shopify/cli-kit/node/crypto'
import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('./session-store.js')
vi.mock('../attribution.js')
vi.mock('@shopify/cli-kit/node/session')
vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)}))
vi.mock('@shopify/cli-kit/node/system', () => ({
openURL: vi.fn().mockResolvedValue(true),
terminalSupportsPrompting: vi.fn().mockReturnValue(true),
}))
vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')}))

describe('store auth service', () => {
beforeEach(() => {
vi.mocked(randomUUID).mockReturnValue('state-123')
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
})

test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => {
const openURL = vi.fn().mockResolvedValue(true)
const presenter = {
Expand Down Expand Up @@ -77,6 +87,92 @@ describe('store auth service', () => {
})
})

test('authenticateStoreWithApp keeps waiting for auth when the terminal cannot prompt', async () => {
const openURL = vi.fn().mockResolvedValue(false)
const presenter = {
openingBrowser: vi.fn(),
manualAuthUrl: vi.fn(),
success: vi.fn(),
}
const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => {
await options.onListening?.()
return 'abc123'
})

const result = await authenticateStoreWithApp(
{
store: 'shop.myshopify.com',
scopes: 'read_products',
},
{
openURL,
presenter,
terminalSupportsPrompting: vi.fn().mockReturnValue(false),
waitForStoreAuthCode: waitForStoreAuthCodeMock,
exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({
access_token: 'token',
scope: 'read_products',
expires_in: 86400,
associated_user: {id: 42, email: 'test@example.com'},
}),
},
)

expect(result).toEqual(
expect.objectContaining({
store: 'shop.myshopify.com',
userId: '42',
scopes: ['read_products'],
}),
)
expect(presenter.openingBrowser).toHaveBeenCalledOnce()
expect(presenter.manualAuthUrl).toHaveBeenCalledWith(
expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'),
)
expect(presenter.success).toHaveBeenCalledWith(result)
})

test('authenticateStoreWithApp returns existing session without auth when non-TTY scopes are already granted', async () => {
const presenter = {
openingBrowser: vi.fn(),
manualAuthUrl: vi.fn(),
success: vi.fn(),
}
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({
store: 'shop.myshopify.com',
clientId: STORE_AUTH_APP_CLIENT_ID,
userId: '42',
accessToken: 'token',
scopes: ['read_products'],
acquiredAt: '2026-03-27T00:00:00.000Z',
associatedUser: {id: 42, email: 'test@example.com'},
})

const result = await authenticateStoreWithApp(
{
store: 'shop.myshopify.com',
scopes: 'read_products',
},
{
presenter,
resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_products'], authoritative: true}),
terminalSupportsPrompting: vi.fn().mockReturnValue(false),
waitForStoreAuthCode: vi.fn(),
exchangeStoreAuthCodeForToken: vi.fn(),
},
)

expect(result).toEqual(
expect.objectContaining({
store: 'shop.myshopify.com',
userId: '42',
scopes: ['read_products'],
associatedUser: expect.objectContaining({email: 'test@example.com'}),
}),
)
expect(presenter.success).toHaveBeenCalledWith(result)
})

test('authenticateStoreWithApp uses remote scopes by default when available', async () => {
const openURL = vi.fn().mockResolvedValue(true)
const presenter = {
Expand Down
118 changes: 80 additions & 38 deletions packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {setStoredStoreAppSession} from './session-store.js'
import {setStoredStoreAppSession, type StoredStoreAppSession} from './session-store.js'
import {exchangeStoreAuthCodeForToken} from './token-client.js'
import {waitForStoreAuthCode} from './callback.js'
import {createPkceBootstrap} from './pkce.js'
import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js'
import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js'
import {loadStoredStoreSession} from './session-lifecycle.js'
import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {openURL} from '@shopify/cli-kit/node/system'
import {openURL, terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
Expand All @@ -24,6 +25,7 @@ interface StoreAuthDependencies {
exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken
resolveExistingScopes: (store: string) => Promise<ResolvedStoreAuthScopes>
presenter: StoreAuthPresenter
terminalSupportsPrompting: typeof terminalSupportsPrompting
}

const defaultStoreAuthDependencies: StoreAuthDependencies = {
Expand All @@ -32,45 +34,31 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = {
exchangeStoreAuthCodeForToken,
resolveExistingScopes: resolveExistingStoreAuthScopes,
presenter: createStoreAuthPresenter('text'),
terminalSupportsPrompting,
}

export async function authenticateStoreWithApp(
input: StoreAuthInput,
dependencies: Partial<StoreAuthDependencies> = {},
): Promise<StoreAuthResult> {
const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies}
const store = normalizeStoreFqdn(input.store)
await recordStoreFqdnMetadata(store, false)
const requestedScopes = parseStoreAuthScopes(input.scopes)
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes

if (existingScopeResolution.scopes.length > 0) {
outputDebug(
outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`,
)
}

const bootstrap = createPkceBootstrap({
store,
function storedSessionToStoreAuthResult(
session: StoredStoreAppSession,
scopes: string[],
acquiredAt = session.acquiredAt,
): StoreAuthResult {
return {
store: session.store,
userId: session.userId,
scopes,
exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken,
})
const {
authorization: {authorizationUrl},
} = bootstrap

resolvedDependencies.presenter.openingBrowser()
acquiredAt,
expiresAt: session.expiresAt,
refreshTokenExpiresAt: session.refreshTokenExpiresAt,
hasRefreshToken: Boolean(session.refreshToken),
associatedUser: session.associatedUser,
}
}

const code = await resolvedDependencies.waitForStoreAuthCode({
...bootstrap.waitForAuthCodeOptions,
onListening: async () => {
const opened = await resolvedDependencies.openURL(authorizationUrl)
if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl)
},
})
const tokenResponse = await bootstrap.exchangeCodeForToken(code)
async function persistStoreAuthToken(
tokenResponse: Awaited<ReturnType<typeof exchangeStoreAuthCodeForToken>>,
store: string,
validationScopes: string[],
): Promise<StoreAuthResult> {
await recordStoreFqdnMetadata(store, true)

const userId = tokenResponse.associated_user?.id?.toString()
Expand All @@ -81,7 +69,6 @@ export async function authenticateStoreWithApp(

const now = Date.now()
const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined

const result: StoreAuthResult = {
store,
userId,
Expand Down Expand Up @@ -120,6 +107,61 @@ export async function authenticateStoreWithApp(
outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`,
)

return result
}

export async function authenticateStoreWithApp(
input: StoreAuthInput,
dependencies: Partial<StoreAuthDependencies> = {},
): Promise<StoreAuthResult> {
const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies}
const store = normalizeStoreFqdn(input.store)
await recordStoreFqdnMetadata(store, false)
const requestedScopes = parseStoreAuthScopes(input.scopes)
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes

if (existingScopeResolution.scopes.length > 0) {
outputDebug(
outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`,
)
}

const bootstrap = createPkceBootstrap({
store,
scopes,
exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken,
})
const {
authorization: {authorizationUrl},
} = bootstrap

if (!resolvedDependencies.terminalSupportsPrompting()) {
const existingMergedScopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
if (
existingScopeResolution.authoritative &&
existingMergedScopes.length === existingScopeResolution.scopes.length &&
existingMergedScopes.every((scope) => existingScopeResolution.scopes.includes(scope))
) {
const session = await loadStoredStoreSession(store)
const result = storedSessionToStoreAuthResult(session, existingScopeResolution.scopes)
resolvedDependencies.presenter.success(result)
return result
}
}

resolvedDependencies.presenter.openingBrowser()

const code = await resolvedDependencies.waitForStoreAuthCode({
...bootstrap.waitForAuthCodeOptions,
onListening: async () => {
const opened = await resolvedDependencies.openURL(authorizationUrl)
if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl)
},
})
const result = await persistStoreAuthToken(await bootstrap.exchangeCodeForToken(code), store, validationScopes)

resolvedDependencies.presenter.success(result)
return result
}
Loading
Loading