Skip to content

Commit 19e0182

Browse files
Add resumable store auth for non-TTY flows
Let store auth start OAuth without waiting in non-TTY contexts, then resume from the browser callback URL to store the app session.
1 parent f07f957 commit 19e0182

13 files changed

Lines changed: 617 additions & 115 deletions

File tree

.changeset/store-auth-resume.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/store': minor
3+
'@shopify/cli': minor
4+
---
5+
6+
Add resumable non-interactive `shopify store auth`.

docs-shopify.dev/commands/interfaces/store-auth.interface.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
* @publicDocs
55
*/
66
export interface storeauth {
7+
/**
8+
* The final callback URL from the browser after authorizing the app.
9+
* @environment SHOPIFY_FLAG_STORE_AUTH_CALLBACK_URL
10+
*/
11+
'--callback-url <value>'?: string
12+
713
/**
814
* Output the result as JSON. Automatically disables color output.
915
* @environment SHOPIFY_FLAG_JSON
@@ -16,17 +22,23 @@ export interface storeauth {
1622
*/
1723
'--no-color'?: ''
1824

25+
/**
26+
* Resume a pending non-interactive store authentication flow.
27+
* @environment SHOPIFY_FLAG_STORE_AUTH_RESUME
28+
*/
29+
'--resume'?: ''
30+
1931
/**
2032
* Comma-separated Admin API scopes to request for the app.
2133
* @environment SHOPIFY_FLAG_SCOPES
2234
*/
23-
'--scopes <value>': string
35+
'--scopes <value>'?: string
2436

2537
/**
2638
* The myshopify.com domain of the store to authenticate against.
2739
* @environment SHOPIFY_FLAG_STORE
2840
*/
29-
'-s, --store <value>': string
41+
'-s, --store <value>'?: string
3042

3143
/**
3244
* Increase the verbosity of the output.

docs-shopify.dev/generated/generated_docs_data_v2.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4175,6 +4175,15 @@
41754175
"description": "The following flags are available for the `store auth` command:",
41764176
"isPublicDocs": true,
41774177
"members": [
4178+
{
4179+
"filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts",
4180+
"syntaxKind": "PropertySignature",
4181+
"name": "--callback-url <value>",
4182+
"value": "string",
4183+
"description": "The final callback URL from the browser after authorizing the app.",
4184+
"isOptional": true,
4185+
"environmentValue": "SHOPIFY_FLAG_STORE_AUTH_CALLBACK_URL"
4186+
},
41784187
{
41794188
"filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts",
41804189
"syntaxKind": "PropertySignature",
@@ -4184,12 +4193,22 @@
41844193
"isOptional": true,
41854194
"environmentValue": "SHOPIFY_FLAG_NO_COLOR"
41864195
},
4196+
{
4197+
"filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts",
4198+
"syntaxKind": "PropertySignature",
4199+
"name": "--resume",
4200+
"value": "''",
4201+
"description": "Resume a pending non-interactive store authentication flow.",
4202+
"isOptional": true,
4203+
"environmentValue": "SHOPIFY_FLAG_STORE_AUTH_RESUME"
4204+
},
41874205
{
41884206
"filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts",
41894207
"syntaxKind": "PropertySignature",
41904208
"name": "--scopes <value>",
41914209
"value": "string",
41924210
"description": "Comma-separated Admin API scopes to request for the app.",
4211+
"isOptional": true,
41934212
"environmentValue": "SHOPIFY_FLAG_SCOPES"
41944213
},
41954214
{
@@ -4216,10 +4235,11 @@
42164235
"name": "-s, --store <value>",
42174236
"value": "string",
42184237
"description": "The myshopify.com domain of the store to authenticate against.",
4238+
"isOptional": true,
42194239
"environmentValue": "SHOPIFY_FLAG_STORE"
42204240
}
42214241
],
4222-
"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}"
4242+
"value": "export interface storeauth {\n /**\n * The final callback URL from the browser after authorizing the app.\n * @environment SHOPIFY_FLAG_STORE_AUTH_CALLBACK_URL\n */\n '--callback-url <value>'?: string\n\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 * Resume a pending non-interactive store authentication flow.\n * @environment SHOPIFY_FLAG_STORE_AUTH_RESUME\n */\n '--resume'?: ''\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}"
42234243
}
42244244
},
42254245
"storeexecute": {

packages/cli/README.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,15 +2157,19 @@ Authenticate an app against a store for store commands.
21572157

21582158
```
21592159
USAGE
2160-
$ shopify store auth --scopes <value> -s <value> [-j] [--no-color] [--verbose]
2160+
$ shopify store auth [--callback-url <value>] [-j] [--no-color] [--resume] [--scopes <value>] [-s <value>]
2161+
[--verbose]
21612162
21622163
FLAGS
2163-
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
2164-
-s, --store=<value> (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate
2165-
against.
2166-
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2167-
--scopes=<value> (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app.
2168-
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
2164+
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
2165+
-s, --store=<value> [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against.
2166+
--callback-url=<value> [env: SHOPIFY_FLAG_STORE_AUTH_CALLBACK_URL] The final callback URL from the browser after
2167+
authorizing the app.
2168+
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2169+
--resume [env: SHOPIFY_FLAG_STORE_AUTH_RESUME] Resume a pending non-interactive store
2170+
authentication flow.
2171+
--scopes=<value> [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app.
2172+
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
21692173
21702174
DESCRIPTION
21712175
Authenticate an app against a store for store commands.
@@ -2175,10 +2179,18 @@ DESCRIPTION
21752179
21762180
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.
21772181
2182+
In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable
2183+
session exists, it starts OAuth, prints the authorization URL, stashes the PKCE state, and exits without waiting.
2184+
2185+
After authorizing in a browser, copy the final callback URL and run `shopify store auth --resume --callback-url
2186+
<callback-url>`.
2187+
21782188
EXAMPLES
21792189
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products
21802190
21812191
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json
2192+
2193+
$ shopify store auth --resume --callback-url "http://127.0.0.1:13387/auth/callback?..."
21822194
```
21832195

21842196
## `shopify store execute`

packages/cli/oclif.manifest.json

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5730,13 +5730,22 @@
57305730
"args": {
57315731
},
57325732
"customPluginName": "@shopify/store",
5733-
"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.",
5734-
"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.",
5733+
"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 a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts OAuth, prints the authorization URL, stashes the PKCE state, and exits without waiting.\n\nAfter authorizing in a browser, copy the final callback URL and run `shopify store auth --resume --callback-url <callback-url>`.",
5734+
"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 a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts OAuth, prints the authorization URL, stashes the PKCE state, and exits without waiting.\n\nAfter authorizing in a browser, copy the final callback URL and run `shopify store auth --resume --callback-url <callback-url>`.",
57355735
"examples": [
57365736
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products",
5737-
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json"
5737+
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json",
5738+
"<%= config.bin %> <%= command.id %> --resume --callback-url \"http://127.0.0.1:13387/auth/callback?...\""
57385739
],
57395740
"flags": {
5741+
"callback-url": {
5742+
"description": "The final callback URL from the browser after authorizing the app.",
5743+
"env": "SHOPIFY_FLAG_STORE_AUTH_CALLBACK_URL",
5744+
"hasDynamicHelp": false,
5745+
"multiple": false,
5746+
"name": "callback-url",
5747+
"type": "option"
5748+
},
57405749
"json": {
57415750
"allowNo": false,
57425751
"char": "j",
@@ -5754,13 +5763,19 @@
57545763
"name": "no-color",
57555764
"type": "boolean"
57565765
},
5766+
"resume": {
5767+
"allowNo": false,
5768+
"description": "Resume a pending non-interactive store authentication flow.",
5769+
"env": "SHOPIFY_FLAG_STORE_AUTH_RESUME",
5770+
"name": "resume",
5771+
"type": "boolean"
5772+
},
57575773
"scopes": {
57585774
"description": "Comma-separated Admin API scopes to request for the app.",
57595775
"env": "SHOPIFY_FLAG_SCOPES",
57605776
"hasDynamicHelp": false,
57615777
"multiple": false,
57625778
"name": "scopes",
5763-
"required": true,
57645779
"type": "option"
57655780
},
57665781
"store": {
@@ -5770,7 +5785,6 @@
57705785
"hasDynamicHelp": false,
57715786
"multiple": false,
57725787
"name": "store",
5773-
"required": true,
57745788
"type": "option"
57755789
},
57765790
"verbose": {

packages/store/src/cli/commands/store/auth.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import StoreAuth from './auth.js'
2-
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
2+
import {authenticateStoreWithApp, resumeStoreAuthWithApp} from '../../services/store/auth/index.js'
33
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
44
import {describe, expect, test, vi} from 'vitest'
55

@@ -36,10 +36,25 @@ describe('store auth command', () => {
3636
)
3737
})
3838

39+
test('resumes store auth with callback URL', async () => {
40+
await StoreAuth.run(['--resume', '--callback-url', 'http://127.0.0.1:13387/auth/callback?code=abc'])
41+
42+
expect(createStoreAuthPresenter).toHaveBeenCalledWith('text')
43+
expect(resumeStoreAuthWithApp).toHaveBeenCalledWith(
44+
{
45+
callbackUrl: 'http://127.0.0.1:13387/auth/callback?code=abc',
46+
},
47+
{presenter: {format: 'text'}},
48+
)
49+
expect(authenticateStoreWithApp).not.toHaveBeenCalled()
50+
})
51+
3952
test('defines the expected flags', () => {
4053
expect(StoreAuth.flags.store).toBeDefined()
4154
expect(StoreAuth.flags.scopes).toBeDefined()
4255
expect(StoreAuth.flags.json).toBeDefined()
56+
expect(StoreAuth.flags.resume).toBeDefined()
57+
expect(StoreAuth.flags['callback-url']).toBeDefined()
4358
expect('port' in StoreAuth.flags).toBe(false)
4459
expect('client-secret-file' in StoreAuth.flags).toBe(false)
4560
})

packages/store/src/cli/commands/store/auth.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
1+
import {authenticateStoreWithApp, resumeStoreAuthWithApp} from '../../services/store/auth/index.js'
22
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
33
import StoreCommand from '../../utilities/store-command.js'
4+
import {AbortError} from '@shopify/cli-kit/node/error'
45
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
56
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
67
import {Flags} from '@oclif/core'
@@ -10,13 +11,18 @@ export default class StoreAuth extends StoreCommand {
1011

1112
static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.
1213
13-
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`
14+
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.
15+
16+
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 OAuth, prints the authorization URL, stashes the PKCE state, and exits without waiting.
17+
18+
After authorizing in a browser, copy the final callback URL and run \`shopify store auth --resume --callback-url <callback-url>\`.`
1419

1520
static description = this.descriptionWithoutMarkdown()
1621

1722
static examples = [
1823
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products',
1924
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json',
25+
'<%= config.bin %> <%= command.id %> --resume --callback-url "http://127.0.0.1:13387/auth/callback?..."',
2026
]
2127

2228
static flags = {
@@ -27,25 +33,51 @@ Re-run this command if the stored token is missing, expires, or no longer has th
2733
description: 'The myshopify.com domain of the store to authenticate against.',
2834
env: 'SHOPIFY_FLAG_STORE',
2935
parse: async (input) => normalizeStoreFqdn(input),
30-
required: true,
3136
}),
3237
scopes: Flags.string({
3338
description: 'Comma-separated Admin API scopes to request for the app.',
3439
env: 'SHOPIFY_FLAG_SCOPES',
35-
required: true,
40+
}),
41+
resume: Flags.boolean({
42+
description: 'Resume a pending non-interactive store authentication flow.',
43+
default: false,
44+
env: 'SHOPIFY_FLAG_STORE_AUTH_RESUME',
45+
}),
46+
'callback-url': Flags.string({
47+
description: 'The final callback URL from the browser after authorizing the app.',
48+
env: 'SHOPIFY_FLAG_STORE_AUTH_CALLBACK_URL',
3649
}),
3750
}
3851

3952
public async run(): Promise<void> {
4053
const {flags} = await this.parse(StoreAuth)
54+
const presenter = createStoreAuthPresenter(flags.json ? 'json' : 'text')
55+
56+
if (flags.resume) {
57+
if (!flags['callback-url']) {
58+
throw new AbortError('Missing callback URL.', 'Pass --callback-url with the final browser callback URL.')
59+
}
60+
61+
await resumeStoreAuthWithApp(
62+
{callbackUrl: flags['callback-url']},
63+
{
64+
presenter,
65+
},
66+
)
67+
return
68+
}
69+
70+
if (!flags.store || !flags.scopes) {
71+
throw new AbortError('Missing required flags.', 'Pass --store and --scopes, or use --resume with --callback-url.')
72+
}
4173

4274
await authenticateStoreWithApp(
4375
{
4476
store: flags.store,
4577
scopes: flags.scopes,
4678
},
4779
{
48-
presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'),
80+
presenter,
4981
},
5082
)
5183
}

0 commit comments

Comments
 (0)