Skip to content

Commit 1c2612a

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 1c2612a

15 files changed

Lines changed: 533 additions & 32 deletions

File tree

.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`.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,10 @@ export interface authlogin {
99
* @environment SHOPIFY_FLAG_AUTH_ALIAS
1010
*/
1111
'--alias <value>'?: string
12+
13+
/**
14+
* Resume a pending non-interactive login flow.
15+
* @environment SHOPIFY_FLAG_AUTH_RESUME
16+
*/
17+
'--resume'?: ''
1218
}

docs-shopify.dev/generated/generated_docs_data_v2.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2592,9 +2592,18 @@
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": "--resume",
2600+
"value": "''",
2601+
"description": "Resume a pending non-interactive login flow.",
2602+
"isOptional": true,
2603+
"environmentValue": "SHOPIFY_FLAG_AUTH_RESUME"
25952604
}
25962605
],
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}"
2606+
"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 * Resume a pending non-interactive login flow.\n * @environment SHOPIFY_FLAG_AUTH_RESUME\n */\n '--resume'?: ''\n}"
25982607
}
25992608
},
26002609
"authlogout": {

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

packages/cli-kit/src/public/node/session-auth-status.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('getAuthStatus', () => {
9797
identityFqdn: 'accounts.shopify.com',
9898
agentGuidance: {
9999
instruction:
100-
'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and keep the command running until authentication completes.',
100+
'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and then run `shopify auth login --resume` after the user authorizes.',
101101
nextCommand: 'shopify auth login',
102102
},
103103
})

0 commit comments

Comments
 (0)