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

Add resumable non-interactive `shopify auth login` with `--resume` and `--new`.
12 changes: 12 additions & 0 deletions docs-shopify.dev/commands/interfaces/auth-login.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,16 @@ export interface authlogin {
* @environment SHOPIFY_FLAG_AUTH_ALIAS
*/
'--alias <value>'?: string

/**
* Log in with a new account instead of choosing from existing sessions.
* @environment SHOPIFY_FLAG_AUTH_NEW
*/
'--new'?: ''

/**
* Resume a pending non-interactive login flow.
* @environment SHOPIFY_FLAG_AUTH_RESUME
*/
'--resume'?: ''
}
20 changes: 19 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -2592,9 +2592,27 @@
"description": "Alias of the session you want to login to.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--new",
"value": "''",
"description": "Log in with a new account instead of choosing from existing sessions.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_AUTH_NEW"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--resume",
"value": "''",
"description": "Resume a pending non-interactive login flow.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_AUTH_RESUME"
}
],
"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}"
"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}"
}
},
"authlogout": {
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The list below contains valuable resources for people interested in contributing

* [Get started](./cli/get-started.md)
* [Architecture](./cli/architecture.md)
* [Authentication](./cli/auth.md)
* [Conventions](./cli/conventions.md)
* [Performance](./cli/performance.md)
* [Debugging](./cli/debugging.md)
Expand Down
48 changes: 48 additions & 0 deletions docs/cli/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Shopify CLI Authentication

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.

## Recommended Flow

1. Check whether a session is already available with `shopify auth status` or `shopify auth status --json`.
2. If a session is available, continue with the command that needs authentication.
3. If no session is available, run `shopify auth login`.
4. Shopify CLI prints a verification URL and user code, or opens the verification URL in your browser.
5. The user completes login in the browser.
6. Complete the CLI flow:
- In an interactive terminal, keep the command running. It polls and continues automatically after authentication succeeds.
- In a non-TTY environment, run `shopify auth login --resume` after the user authorizes.

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.

## Commands

- `shopify auth status`: Check whether Shopify CLI has a usable Shopify account session. Use `--json` for stable machine-readable output.
- `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.
- `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.
- `shopify auth login --new`: Start a new login instead of reusing or choosing from existing sessions.
- `shopify auth logout`: Clear the stored Shopify CLI session.
- Commands that need authentication may start the same login flow automatically when no usable session exists in an interactive terminal.

## Non-TTY Behavior

When `shopify auth login` runs without a TTY:

1. Shopify CLI checks for an existing usable session.
2. If a session exists, Shopify CLI prints the current account and exits without starting a new login.
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.
4. After the user authorizes in the browser, run `shopify auth login --resume`.

Use `shopify auth login --new` to skip the existing-session check and start a new device authorization flow.

## CI

Do not start browser-based login from CI. Use credentials provided through the supported environment variables for the command you are running.

## Scopes

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.

## Support

For issues with Shopify CLI authentication, see https://shopify.dev/docs/api/shopify-cli or contact Shopify support at https://help.shopify.com.
31 changes: 31 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
getCachedPartnerAccountStatus,
setCachedPartnerAccountStatus,
runWithRateLimit,
clearPendingDeviceAuth,
getPendingDeviceAuth,
setPendingDeviceAuth,
} from './conf-store.js'
import {isLocalEnvironment} from './context/service.js'
import {LocalStorage} from '../../public/node/local-storage.js'
Expand Down Expand Up @@ -194,6 +197,34 @@ describe('removeCurrentSessionId', () => {
})
})

describe('pending device auth', () => {
test('stores, reads, and clears pending device auth', async () => {
await inTemporaryDirectory(async (cwd) => {
// Given
const config = new LocalStorage<ConfSchema>({cwd})
const pendingDeviceAuth = {
deviceCode: 'device-code',
userCode: 'user-code',
verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=user-code',
interval: 5,
expiresAt: 1893456000000,
}

// When
setPendingDeviceAuth(pendingDeviceAuth, config)

// Then
expect(getPendingDeviceAuth(config)).toEqual(pendingDeviceAuth)

// When
clearPendingDeviceAuth(config)

// Then
expect(getPendingDeviceAuth(config)).toBeUndefined()
})
})
})

describe('session environment isolation', () => {
test('getSessions returns production sessions in production', async () => {
await inTemporaryDirectory(async (cwd) => {
Expand Down
34 changes: 34 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,22 @@ interface Cache {
[rateLimitKey: RateLimitKey]: CacheValue<number[]>
}

export interface PendingDeviceAuth {
deviceCode: string
userCode: string
verificationUriComplete: string
interval: number
expiresAt: number
}

export interface ConfSchema {
sessionStore: string
currentSessionId?: string
devSessionStore?: string
currentDevSessionId?: string
cache?: Cache
autoUpgradeEnabled?: boolean
pendingDeviceAuth?: PendingDeviceAuth
}

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

/**
* Get pending device auth state for a resumable non-interactive login flow.
*
* @returns Pending device auth state, if present.
*/
export function getPendingDeviceAuth(config: LocalStorage<ConfSchema> = cliKitStore()): PendingDeviceAuth | undefined {
return config.get('pendingDeviceAuth')
}

/**
* Stash pending device auth state for a later `shopify auth login --resume`.
*
* @param auth - Pending device auth state.
*/
export function setPendingDeviceAuth(auth: PendingDeviceAuth, config: LocalStorage<ConfSchema> = cliKitStore()): void {
config.set('pendingDeviceAuth', auth)
}

/**
* Clear pending device auth state after completion or expiry.
*/
export function clearPendingDeviceAuth(config: LocalStorage<ConfSchema> = cliKitStore()): void {
config.delete('pendingDeviceAuth')
}

type CacheValueForKey<TKey extends keyof Cache> = NonNullable<Cache[TKey]>['value']

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,22 @@ The CLI is currently unable to prompt for reauthentication.`,
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('unknown')
})

test('throws an authentication required error if the terminal cannot prompt', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
vi.mocked(fetchSessions).mockResolvedValue(undefined)
vi.mocked(terminalSupportsPrompting).mockReturnValue(false)

// When
await expect(ensureAuthenticated(defaultApplications)).rejects.toThrow('Authentication required')

// Then
expect(requestDeviceAuthorization).not.toHaveBeenCalled()
expect(pollForDeviceAuthorization).not.toHaveBeenCalled()
expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled()
expect(storeSessions).not.toHaveBeenCalled()
})

test('executes complete auth flow if session is for a different fqdn', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
Expand Down
51 changes: 41 additions & 10 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {AdminSession, logout} from '../../public/node/session.js'
import {nonRandomUUID} from '../../public/node/crypto.js'
import {isEmpty} from '../../public/common/object.js'
import {businessPlatformRequest} from '../../public/node/api/business-platform.js'
import {terminalSupportsPrompting} from '../../public/node/system.js'

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

let identityToken: IdentityToken
const identityTokenInformation = getIdentityTokenInformation()
if (identityTokenInformation) {
identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation)
} else {
if (!terminalSupportsPrompting()) {
throw new AbortError('Authentication required', [
'Run',
{command: 'shopify auth login'},
'first or use a valid authentication token',
])
}

// Request a device code to authorize without a browser redirect.
outputDebug(outputContent`Requesting device authorization code...`)
const deviceAuth = await requestDeviceAuthorization(scopes)
Expand All @@ -315,6 +318,28 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
}

const session = await completeAuthFlow(identityToken, applications, existingAlias)
outputCompleted(`Logged in.`)
return session
}

/**
* Given an identity token, exchange it for application tokens and build a complete session.
* Shared between the interactive login flow and the resumable non-interactive flow.
*
* @param identityToken - Identity token returned by the OAuth device code flow.
* @param applications - Applications to exchange access tokens for.
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
* @returns A complete session with identity and application tokens.
*/
export async function completeAuthFlow(
identityToken: IdentityToken,
applications: OAuthApplications,
existingAlias?: string,
): Promise<Session> {
const exchangeScopes = getExchangeScopes(applications)
const store = applications.adminApi?.storeFqdn

// Exchange identity token for application tokens
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
Expand All @@ -330,9 +355,6 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
},
applications: result,
}

outputCompleted(`Logged in.`)

return session
}

Expand Down Expand Up @@ -419,6 +441,15 @@ function getFlattenScopes(apps: OAuthApplications): string[] {
return allDefaultScopes(requestedScopes)
}

function getDeviceAuthScopes(applications: OAuthApplications): string[] {
const scopes = getFlattenScopes(applications)
if (firstPartyDev()) {
outputDebug(outputContent`Authenticating as Shopify Employee...`)
scopes.push('employee')
}
return scopes
}

/**
* Get the scopes for the given applications.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import {IdentityToken} from './schema.js'
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {isTTY} from '../../../public/node/ui.js'
import {isTTY, keypress} from '../../../public/node/ui.js'
import {err, ok} from '../../../public/node/result.js'
import {AbortError} from '../../../public/node/error.js'
import {isCI} from '../../../public/node/system.js'
import {isCI, openURL} from '../../../public/node/system.js'
import {mockAndCaptureOutput} from '../../../public/node/testing/output.js'

import {beforeEach, describe, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'
Expand Down Expand Up @@ -66,6 +67,26 @@ describe('requestDeviceAuthorization', () => {
expect(got).toEqual(dataExpected)
})

test('can request a device auth code without prompting or polling', async () => {
// Given
const outputMock = mockAndCaptureOutput()
const response = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
vi.mocked(isCI).mockReturnValue(true)

// When
const got = await requestDeviceAuthorization(['scope1', 'scope2'], {noPrompt: true})

// Then
expect(got).toEqual(dataExpected)
expect(keypress).not.toHaveBeenCalled()
expect(openURL).not.toHaveBeenCalled()
expect(outputMock.info()).toContain('User verification code: user_code')
expect(outputMock.info()).toContain('verification_uri_complete')
})

test('when the response is not valid JSON, throw an error with context', async () => {
// Given
const response = new Response('not valid JSON')
Expand Down
Loading
Loading