Skip to content

Commit a14814d

Browse files
Add resumable auth login for non-TTY flows
Allow agents to start Shopify auth without blocking on device-code polling, then resume the stashed device code exchange after the user authorizes.
1 parent 16b9df4 commit a14814d

20 files changed

Lines changed: 785 additions & 42 deletions

.changeset/resumable-auth-login.md

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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,16 @@ export interface authlogin {
99
* @environment SHOPIFY_FLAG_AUTH_ALIAS
1010
*/
1111
'--alias <value>'?: string
12+
13+
/**
14+
* Log in with a new account instead of choosing from existing sessions.
15+
* @environment SHOPIFY_FLAG_AUTH_NEW
16+
*/
17+
'--new'?: ''
18+
19+
/**
20+
* Resume a pending non-interactive login flow.
21+
* @environment SHOPIFY_FLAG_AUTH_RESUME
22+
*/
23+
'--resume'?: ''
1224
}

docs-shopify.dev/generated/generated_docs_data_v2.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2592,9 +2592,27 @@
25922592
"description": "Alias of the session you want to login to.",
25932593
"isOptional": true,
25942594
"environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS"
2595+
},
2596+
{
2597+
"filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts",
2598+
"syntaxKind": "PropertySignature",
2599+
"name": "--new",
2600+
"value": "''",
2601+
"description": "Log in with a new account instead of choosing from existing sessions.",
2602+
"isOptional": true,
2603+
"environmentValue": "SHOPIFY_FLAG_AUTH_NEW"
2604+
},
2605+
{
2606+
"filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts",
2607+
"syntaxKind": "PropertySignature",
2608+
"name": "--resume",
2609+
"value": "''",
2610+
"description": "Resume a pending non-interactive login flow.",
2611+
"isOptional": true,
2612+
"environmentValue": "SHOPIFY_FLAG_AUTH_RESUME"
25952613
}
25962614
],
2597-
"value": "export interface authlogin {\n /**\n * Alias of the session you want to login to.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias <value>'?: string\n}"
2615+
"value": "export interface authlogin {\n /**\n * Alias of the session you want to login to.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias <value>'?: string\n\n /**\n * Log in with a new account instead of choosing from existing sessions.\n * @environment SHOPIFY_FLAG_AUTH_NEW\n */\n '--new'?: ''\n\n /**\n * Resume a pending non-interactive login flow.\n * @environment SHOPIFY_FLAG_AUTH_RESUME\n */\n '--resume'?: ''\n}"
25982616
}
25992617
},
26002618
"authlogout": {

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The list below contains valuable resources for people interested in contributing
1212

1313
* [Get started](./cli/get-started.md)
1414
* [Architecture](./cli/architecture.md)
15+
* [Authentication](./cli/auth.md)
1516
* [Conventions](./cli/conventions.md)
1617
* [Performance](./cli/performance.md)
1718
* [Debugging](./cli/debugging.md)

docs/cli/auth.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Shopify CLI Authentication
2+
3+
Shopify CLI authenticates developers with Shopify through a device-code OAuth flow. This works in local terminals, remote development environments, and agent-driven workflows where a browser might not be available to the CLI process.
4+
5+
## Recommended Flow
6+
7+
1. Check whether a session is already available with `shopify auth status` or `shopify auth status --json`.
8+
2. If a session is available, continue with the command that needs authentication.
9+
3. If no session is available, run `shopify auth login`.
10+
4. Shopify CLI prints a verification URL and user code, or opens the verification URL in your browser.
11+
5. The user completes login in the browser.
12+
6. Complete the CLI flow:
13+
- In an interactive terminal, keep the command running. It polls and continues automatically after authentication succeeds.
14+
- In a non-TTY environment, run `shopify auth login --resume` after the user authorizes.
15+
16+
Agents should show the verification URL and user code to the user, ask the user to complete authentication in the browser, and then either wait for the interactive command to finish or run `shopify auth login --resume` for a non-TTY login.
17+
18+
## Commands
19+
20+
- `shopify auth status`: Check whether Shopify CLI has a usable Shopify account session. Use `--json` for stable machine-readable output.
21+
- `shopify auth login`: Log in to a Shopify account. In a non-TTY environment, this starts the device-code flow, prints the verification URL and code, stashes the device code, and exits without waiting.
22+
- `shopify auth login --resume`: Resume a pending non-TTY login after the user has authorized in the browser. On success, Shopify CLI exchanges the stashed device code for tokens and stores the session.
23+
- `shopify auth login --new`: Start a new login instead of reusing or choosing from existing sessions.
24+
- `shopify auth logout`: Clear the stored Shopify CLI session.
25+
- Commands that need authentication may start the same login flow automatically when no usable session exists.
26+
27+
## Non-TTY Behavior
28+
29+
When `shopify auth login` runs without a TTY:
30+
31+
1. Shopify CLI checks for an existing usable session.
32+
2. If a session exists, Shopify CLI prints the current account and exits without starting a new login.
33+
3. If no session exists, Shopify CLI starts device authorization, prints the verification URL and user code, stores the pending device code, and exits immediately.
34+
4. After the user authorizes in the browser, run `shopify auth login --resume`.
35+
36+
Use `shopify auth login --new` to skip the existing-session check and start a new device authorization flow.
37+
38+
## CI
39+
40+
Do not start browser-based login from CI. Use credentials provided through the supported environment variables for the command you are running.
41+
42+
## Scopes
43+
44+
Shopify CLI requests the scopes needed for CLI workflows, including access to Shopify Admin, Partners, Storefront Renderer, Business Platform, and App Management APIs. Individual commands may request additional scopes for the task being performed.
45+
46+
## Support
47+
48+
For issues with Shopify CLI authentication, see https://shopify.dev/docs/api/shopify-cli or contact Shopify support at https://help.shopify.com.

packages/cli-kit/src/private/node/conf-store.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
getCachedPartnerAccountStatus,
1414
setCachedPartnerAccountStatus,
1515
runWithRateLimit,
16+
clearPendingDeviceAuth,
17+
getPendingDeviceAuth,
18+
setPendingDeviceAuth,
1619
} from './conf-store.js'
1720
import {isLocalEnvironment} from './context/service.js'
1821
import {LocalStorage} from '../../public/node/local-storage.js'
@@ -194,6 +197,34 @@ describe('removeCurrentSessionId', () => {
194197
})
195198
})
196199

200+
describe('pending device auth', () => {
201+
test('stores, reads, and clears pending device auth', async () => {
202+
await inTemporaryDirectory(async (cwd) => {
203+
// Given
204+
const config = new LocalStorage<ConfSchema>({cwd})
205+
const pendingDeviceAuth = {
206+
deviceCode: 'device-code',
207+
userCode: 'user-code',
208+
verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=user-code',
209+
interval: 5,
210+
expiresAt: 1893456000000,
211+
}
212+
213+
// When
214+
setPendingDeviceAuth(pendingDeviceAuth, config)
215+
216+
// Then
217+
expect(getPendingDeviceAuth(config)).toEqual(pendingDeviceAuth)
218+
219+
// When
220+
clearPendingDeviceAuth(config)
221+
222+
// Then
223+
expect(getPendingDeviceAuth(config)).toBeUndefined()
224+
})
225+
})
226+
})
227+
197228
describe('session environment isolation', () => {
198229
test('getSessions returns production sessions in production', async () => {
199230
await inTemporaryDirectory(async (cwd) => {

packages/cli-kit/src/private/node/conf-store.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,22 @@ interface Cache {
2626
[rateLimitKey: RateLimitKey]: CacheValue<number[]>
2727
}
2828

29+
export interface PendingDeviceAuth {
30+
deviceCode: string
31+
userCode: string
32+
verificationUriComplete: string
33+
interval: number
34+
expiresAt: number
35+
}
36+
2937
export interface ConfSchema {
3038
sessionStore: string
3139
currentSessionId?: string
3240
devSessionStore?: string
3341
currentDevSessionId?: string
3442
cache?: Cache
3543
autoUpgradeEnabled?: boolean
44+
pendingDeviceAuth?: PendingDeviceAuth
3645
}
3746

3847
let _instance: LocalStorage<ConfSchema> | undefined
@@ -111,6 +120,31 @@ export function removeCurrentSessionId(config: LocalStorage<ConfSchema> = cliKit
111120
config.delete(currentSessionIdKey())
112121
}
113122

123+
/**
124+
* Get pending device auth state for a resumable non-interactive login flow.
125+
*
126+
* @returns Pending device auth state, if present.
127+
*/
128+
export function getPendingDeviceAuth(config: LocalStorage<ConfSchema> = cliKitStore()): PendingDeviceAuth | undefined {
129+
return config.get('pendingDeviceAuth')
130+
}
131+
132+
/**
133+
* Stash pending device auth state for a later `shopify auth login --resume`.
134+
*
135+
* @param auth - Pending device auth state.
136+
*/
137+
export function setPendingDeviceAuth(auth: PendingDeviceAuth, config: LocalStorage<ConfSchema> = cliKitStore()): void {
138+
config.set('pendingDeviceAuth', auth)
139+
}
140+
141+
/**
142+
* Clear pending device auth state after completion or expiry.
143+
*/
144+
export function clearPendingDeviceAuth(config: LocalStorage<ConfSchema> = cliKitStore()): void {
145+
config.delete('pendingDeviceAuth')
146+
}
147+
114148
type CacheValueForKey<TKey extends keyof Cache> = NonNullable<Cache[TKey]>['value']
115149

116150
/**

packages/cli-kit/src/private/node/session.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,6 @@ The CLI is currently unable to prompt for reauthentication.`,
294294
*/
295295
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
296296
const scopes = getFlattenScopes(applications)
297-
const exchangeScopes = getExchangeScopes(applications)
298-
const store = applications.adminApi?.storeFqdn
299297
if (firstPartyDev()) {
300298
outputDebug(outputContent`Authenticating as Shopify Employee...`)
301299
scopes.push('employee')
@@ -315,6 +313,28 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
315313
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
316314
}
317315

316+
const session = await completeAuthFlow(identityToken, applications, existingAlias)
317+
outputCompleted(`Logged in.`)
318+
return session
319+
}
320+
321+
/**
322+
* Given an identity token, exchange it for application tokens and build a complete session.
323+
* Shared between the interactive login flow and the resumable non-interactive flow.
324+
*
325+
* @param identityToken - Identity token returned by the OAuth device code flow.
326+
* @param applications - Applications to exchange access tokens for.
327+
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
328+
* @returns A complete session with identity and application tokens.
329+
*/
330+
export async function completeAuthFlow(
331+
identityToken: IdentityToken,
332+
applications: OAuthApplications,
333+
existingAlias?: string,
334+
): Promise<Session> {
335+
const exchangeScopes = getExchangeScopes(applications)
336+
const store = applications.adminApi?.storeFqdn
337+
318338
// Exchange identity token for application tokens
319339
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
320340
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
@@ -330,9 +350,6 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
330350
},
331351
applications: result,
332352
}
333-
334-
outputCompleted(`Logged in.`)
335-
336353
return session
337354
}
338355

packages/cli-kit/src/private/node/session/device-authorization.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import {IdentityToken} from './schema.js'
88
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
99
import {identityFqdn} from '../../../public/node/context/fqdn.js'
1010
import {shopifyFetch} from '../../../public/node/http.js'
11-
import {isTTY} from '../../../public/node/ui.js'
11+
import {isTTY, keypress} from '../../../public/node/ui.js'
1212
import {err, ok} from '../../../public/node/result.js'
1313
import {AbortError} from '../../../public/node/error.js'
14-
import {isCI} from '../../../public/node/system.js'
14+
import {isCI, openURL} from '../../../public/node/system.js'
15+
import {mockAndCaptureOutput} from '../../../public/node/testing/output.js'
1516

1617
import {beforeEach, describe, expect, test, vi} from 'vitest'
1718
import {Response} from 'node-fetch'
@@ -24,6 +25,7 @@ vi.mock('./exchange.js')
2425
vi.mock('../../../public/node/system.js')
2526

2627
beforeEach(() => {
28+
vi.clearAllMocks()
2729
vi.mocked(isTTY).mockReturnValue(true)
2830
vi.mocked(isCI).mockReturnValue(false)
2931
})
@@ -66,6 +68,26 @@ describe('requestDeviceAuthorization', () => {
6668
expect(got).toEqual(dataExpected)
6769
})
6870

71+
test('can request a device auth code without prompting or polling', async () => {
72+
// Given
73+
const outputMock = mockAndCaptureOutput()
74+
const response = new Response(JSON.stringify(data))
75+
vi.mocked(shopifyFetch).mockResolvedValue(response)
76+
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
77+
vi.mocked(clientId).mockReturnValue('clientId')
78+
vi.mocked(isCI).mockReturnValue(true)
79+
80+
// When
81+
const got = await requestDeviceAuthorization(['scope1', 'scope2'], {noPrompt: true})
82+
83+
// Then
84+
expect(got).toEqual(dataExpected)
85+
expect(keypress).not.toHaveBeenCalled()
86+
expect(openURL).not.toHaveBeenCalled()
87+
expect(outputMock.info()).toContain('User verification code: user_code')
88+
expect(outputMock.info()).toContain('verification_uri_complete')
89+
})
90+
6991
test('when the response is not valid JSON, throw an error with context', async () => {
7092
// Given
7193
const response = new Response('not valid JSON')

packages/cli-kit/src/private/node/session/device-authorization.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,13 @@ export interface DeviceAuthorizationResponse {
2828
* Also returns a `deviceCode` used for polling the token endpoint in the next step.
2929
*
3030
* @param scopes - The scopes to request
31+
* @param options - Optional settings for presenting the device authorization instructions.
3132
* @returns An object with the device authorization response.
3233
*/
33-
export async function requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> {
34+
export async function requestDeviceAuthorization(
35+
scopes: string[],
36+
{noPrompt = false}: {noPrompt?: boolean} = {},
37+
): Promise<DeviceAuthorizationResponse> {
3438
const fqdn = await identityFqdn()
3539
const identityClientId = clientId()
3640
const queryParams = {client_id: identityClientId, scope: scopes.join(' ')}
@@ -69,32 +73,39 @@ export async function requestDeviceAuthorization(scopes: string[]): Promise<Devi
6973
throw new BugError('Failed to start authorization process')
7074
}
7175

72-
outputInfo('\nTo run this command, log in to Shopify.')
73-
74-
if (isCI()) {
75-
throw new AbortError(
76-
'Authorization is required to continue, but the current environment does not support interactive prompts.',
77-
'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.',
78-
)
79-
}
80-
81-
outputInfo(outputContent`User verification code: ${jsonResult.user_code}`)
8276
const linkToken = outputToken.link(jsonResult.verification_uri_complete)
8377

8478
const cloudMessage = () => {
8579
outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`)
8680
}
8781

88-
if (isCloudEnvironment() || !isTTY()) {
82+
if (noPrompt) {
83+
outputInfo('\nTo log in to Shopify, open the following URL and enter the verification code.')
84+
outputInfo(outputContent`User verification code: ${jsonResult.user_code}`)
8985
cloudMessage()
9086
} else {
91-
outputInfo('👉 Press any key to open the login page on your browser')
92-
await keypress()
93-
const opened = await openURL(jsonResult.verification_uri_complete)
94-
if (opened) {
95-
outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`)
96-
} else {
87+
outputInfo('\nTo run this command, log in to Shopify.')
88+
89+
if (isCI()) {
90+
throw new AbortError(
91+
'Authorization is required to continue, but the current environment does not support interactive prompts.',
92+
'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.',
93+
)
94+
}
95+
96+
outputInfo(outputContent`User verification code: ${jsonResult.user_code}`)
97+
98+
if (isCloudEnvironment() || !isTTY()) {
9799
cloudMessage()
100+
} else {
101+
outputInfo('👉 Press any key to open the login page on your browser')
102+
await keypress()
103+
const opened = await openURL(jsonResult.verification_uri_complete)
104+
if (opened) {
105+
outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`)
106+
} else {
107+
cloudMessage()
108+
}
98109
}
99110
}
100111

0 commit comments

Comments
 (0)