Skip to content

Commit f07f957

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 f07f957

21 files changed

Lines changed: 815 additions & 47 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 in an interactive terminal.
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.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,22 @@ The CLI is currently unable to prompt for reauthentication.`,
198198
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('unknown')
199199
})
200200

201+
test('throws an authentication required error if the terminal cannot prompt', async () => {
202+
// Given
203+
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
204+
vi.mocked(fetchSessions).mockResolvedValue(undefined)
205+
vi.mocked(terminalSupportsPrompting).mockReturnValue(false)
206+
207+
// When
208+
await expect(ensureAuthenticated(defaultApplications)).rejects.toThrow('Authentication required')
209+
210+
// Then
211+
expect(requestDeviceAuthorization).not.toHaveBeenCalled()
212+
expect(pollForDeviceAuthorization).not.toHaveBeenCalled()
213+
expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled()
214+
expect(storeSessions).not.toHaveBeenCalled()
215+
})
216+
201217
test('executes complete auth flow if session is for a different fqdn', async () => {
202218
// Given
203219
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')

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

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {AdminSession, logout} from '../../public/node/session.js'
2424
import {nonRandomUUID} from '../../public/node/crypto.js'
2525
import {isEmpty} from '../../public/common/object.js'
2626
import {businessPlatformRequest} from '../../public/node/api/business-platform.js'
27+
import {terminalSupportsPrompting} from '../../public/node/system.js'
2728

2829
/**
2930
* Fetches the user's email from the Business Platform API
@@ -293,19 +294,21 @@ The CLI is currently unable to prompt for reauthentication.`,
293294
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
294295
*/
295296
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
296-
const scopes = getFlattenScopes(applications)
297-
const exchangeScopes = getExchangeScopes(applications)
298-
const store = applications.adminApi?.storeFqdn
299-
if (firstPartyDev()) {
300-
outputDebug(outputContent`Authenticating as Shopify Employee...`)
301-
scopes.push('employee')
302-
}
297+
const scopes = getDeviceAuthScopes(applications)
303298

304299
let identityToken: IdentityToken
305300
const identityTokenInformation = getIdentityTokenInformation()
306301
if (identityTokenInformation) {
307302
identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation)
308303
} else {
304+
if (!terminalSupportsPrompting()) {
305+
throw new AbortError('Authentication required', [
306+
'Run',
307+
{command: 'shopify auth login'},
308+
'first or use a valid authentication token',
309+
])
310+
}
311+
309312
// Request a device code to authorize without a browser redirect.
310313
outputDebug(outputContent`Requesting device authorization code...`)
311314
const deviceAuth = await requestDeviceAuthorization(scopes)
@@ -315,6 +318,28 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
315318
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
316319
}
317320

321+
const session = await completeAuthFlow(identityToken, applications, existingAlias)
322+
outputCompleted(`Logged in.`)
323+
return session
324+
}
325+
326+
/**
327+
* Given an identity token, exchange it for application tokens and build a complete session.
328+
* Shared between the interactive login flow and the resumable non-interactive flow.
329+
*
330+
* @param identityToken - Identity token returned by the OAuth device code flow.
331+
* @param applications - Applications to exchange access tokens for.
332+
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
333+
* @returns A complete session with identity and application tokens.
334+
*/
335+
export async function completeAuthFlow(
336+
identityToken: IdentityToken,
337+
applications: OAuthApplications,
338+
existingAlias?: string,
339+
): Promise<Session> {
340+
const exchangeScopes = getExchangeScopes(applications)
341+
const store = applications.adminApi?.storeFqdn
342+
318343
// Exchange identity token for application tokens
319344
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
320345
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
@@ -330,9 +355,6 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
330355
},
331356
applications: result,
332357
}
333-
334-
outputCompleted(`Logged in.`)
335-
336358
return session
337359
}
338360

@@ -419,6 +441,15 @@ function getFlattenScopes(apps: OAuthApplications): string[] {
419441
return allDefaultScopes(requestedScopes)
420442
}
421443

444+
function getDeviceAuthScopes(applications: OAuthApplications): string[] {
445+
const scopes = getFlattenScopes(applications)
446+
if (firstPartyDev()) {
447+
outputDebug(outputContent`Authenticating as Shopify Employee...`)
448+
scopes.push('employee')
449+
}
450+
return scopes
451+
}
452+
422453
/**
423454
* Get the scopes for the given applications.
424455
*

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

Lines changed: 23 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'
@@ -66,6 +67,26 @@ describe('requestDeviceAuthorization', () => {
6667
expect(got).toEqual(dataExpected)
6768
})
6869

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

0 commit comments

Comments
 (0)