Skip to content
Closed
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/store-auth-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/store': minor
'@shopify/cli': minor
---

Add resumable non-interactive `shopify store auth`.
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ export interface storeauth {
* Comma-separated Admin API scopes to request for the app.
* @environment SHOPIFY_FLAG_SCOPES
*/
'--scopes <value>': string
'--scopes <value>'?: string

/**
* The myshopify.com domain of the store to authenticate against.
* @environment SHOPIFY_FLAG_STORE
*/
'-s, --store <value>': string
'-s, --store <value>'?: string

/**
* Increase the verbosity of the output.
Expand Down
4 changes: 3 additions & 1 deletion 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": true,
"environmentValue": "SHOPIFY_FLAG_SCOPES"
},
{
Expand All @@ -4216,10 +4217,11 @@
"name": "-s, --store <value>",
"value": "string",
"description": "The myshopify.com domain of the store to authenticate against.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
"value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes <value>': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes <value>'?: string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"storeexecute": {
Expand Down
19 changes: 12 additions & 7 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2157,15 +2157,14 @@ Authenticate an app against a store for store commands.

```
USAGE
$ shopify store auth --scopes <value> -s <value> [-j] [--no-color] [--verbose]
$ shopify store auth [-j] [--no-color] [--scopes <value>] [-s <value>] [--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> [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> [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 +2174,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
6 changes: 2 additions & 4 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 Expand Up @@ -5760,7 +5760,6 @@
"hasDynamicHelp": false,
"multiple": false,
"name": "scopes",
"required": true,
"type": "option"
},
"store": {
Expand All @@ -5770,7 +5769,6 @@
"hasDynamicHelp": false,
"multiple": false,
"name": "store",
"required": true,
"type": "option"
},
"verbose": {
Expand Down
2 changes: 2 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,8 @@ describe('store auth command', () => {
expect(StoreAuth.flags.store).toBeDefined()
expect(StoreAuth.flags.scopes).toBeDefined()
expect(StoreAuth.flags.json).toBeDefined()
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
16 changes: 12 additions & 4 deletions packages/store/src/cli/commands/store/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
import StoreCommand from '../../utilities/store-command.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {Flags} from '@oclif/core'
Expand All @@ -10,7 +11,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 All @@ -27,25 +32,28 @@ Re-run this command if the stored token is missing, expires, or no longer has th
description: 'The myshopify.com domain of the store to authenticate against.',
env: 'SHOPIFY_FLAG_STORE',
parse: async (input) => normalizeStoreFqdn(input),
required: true,
}),
scopes: Flags.string({
description: 'Comma-separated Admin API scopes to request for the app.',
env: 'SHOPIFY_FLAG_SCOPES',
required: true,
}),
}

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

if (!flags.store || !flags.scopes) {
throw new AbortError('Missing required flags.', 'Pass --store and --scopes.')
}

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
Loading
Loading