diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 811b2c9432..e011d0cebd 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4190,6 +4190,7 @@ "name": "--scopes ", "value": "string", "description": "Comma-separated Admin API scopes to request for the app.", + "isOptional": false, "environmentValue": "SHOPIFY_FLAG_SCOPES" }, { @@ -4216,6 +4217,7 @@ "name": "-s, --store ", "value": "string", "description": "The myshopify.com domain of the store to authenticate against.", + "isOptional": false, "environmentValue": "SHOPIFY_FLAG_STORE" } ], diff --git a/packages/cli/README.md b/packages/cli/README.md index 4f7060bb8a..ebc53ac7a0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2160,12 +2160,13 @@ USAGE $ shopify store auth --scopes -s [-j] [--no-color] [--verbose] FLAGS - -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. - -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate - against. - --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. - --scopes= (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app. - --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate + against. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --scopes= (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the + app. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. DESCRIPTION Authenticate an app against a store for store commands. @@ -2175,6 +2176,12 @@ DESCRIPTION Re-run this command if the stored token is missing, expires, or no longer has the scopes you need. + In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. + Agents should keep the command running until the browser authorization finishes. + + 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 the same OAuth flow and waits for authentication to complete. + EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a09a174ed0..990aba1360 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5730,8 +5730,8 @@ "args": { }, "customPluginName": "@shopify/store", - "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.", - "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.", + "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 an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.\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 the same OAuth flow and waits for authentication to complete.", + "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 an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.\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 the same OAuth flow and waits for authentication to complete.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json" diff --git a/packages/store/src/cli/commands/store/auth.test.ts b/packages/store/src/cli/commands/store/auth.test.ts index 2b3e0efa85..f013b32246 100644 --- a/packages/store/src/cli/commands/store/auth.test.ts +++ b/packages/store/src/cli/commands/store/auth.test.ts @@ -40,6 +40,10 @@ describe('store auth command', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() expect(StoreAuth.flags.json).toBeDefined() + expect(StoreAuth.flags.store.required).toBe(true) + expect(StoreAuth.flags.scopes.required).toBe(true) + expect('resume' in StoreAuth.flags).toBe(false) + expect('callback-url' in StoreAuth.flags).toBe(false) expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/store/src/cli/commands/store/auth.ts b/packages/store/src/cli/commands/store/auth.ts index 9a7c0eb2fd..a13b847f7e 100644 --- a/packages/store/src/cli/commands/store/auth.ts +++ b/packages/store/src/cli/commands/store/auth.ts @@ -10,7 +10,11 @@ export default class StoreAuth extends StoreCommand { static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse. -Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.` +Re-run this command if the stored token is missing, expires, or no longer has the scopes you need. + +In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes. + +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 the same OAuth flow and waits for authentication to complete.` static description = this.descriptionWithoutMarkdown() @@ -38,6 +42,7 @@ Re-run this command if the stored token is missing, expires, or no longer has th public async run(): Promise { const {flags} = await this.parse(StoreAuth) + const presenter = createStoreAuthPresenter(flags.json ? 'json' : 'text') await authenticateStoreWithApp( { @@ -45,7 +50,7 @@ Re-run this command if the stored token is missing, expires, or no longer has th scopes: flags.scopes, }, { - presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + presenter, }, ) } diff --git a/packages/store/src/cli/services/store/auth/index.test.ts b/packages/store/src/cli/services/store/auth/index.test.ts index a02c10ce5a..8a0cf653d2 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,17 +1,27 @@ import {authenticateStoreWithApp} from './index.js' -import {setStoredStoreAppSession} from './session-store.js' +import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' -import {describe, expect, test, vi} from 'vitest' +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('./session-store.js') vi.mock('../attribution.js') vi.mock('@shopify/cli-kit/node/session') -vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) +vi.mock('@shopify/cli-kit/node/system', () => ({ + openURL: vi.fn().mockResolvedValue(true), + terminalSupportsPrompting: vi.fn().mockReturnValue(true), +})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) describe('store auth service', () => { + beforeEach(() => { + vi.mocked(randomUUID).mockReturnValue('state-123') + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + }) + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { @@ -77,6 +87,92 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp keeps waiting for auth when the terminal cannot prompt', async () => { + const openURL = vi.fn().mockResolvedValue(false) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + const result = await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + presenter, + terminalSupportsPrompting: vi.fn().mockReturnValue(false), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + }, + ) + + expect(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + }), + ) + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(presenter.manualAuthUrl).toHaveBeenCalledWith( + expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), + ) + expect(presenter.success).toHaveBeenCalledWith(result) + }) + + test('authenticateStoreWithApp returns existing session without auth when non-TTY scopes are already granted', async () => { + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + associatedUser: {id: 42, email: 'test@example.com'}, + }) + + const result = await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + presenter, + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_products'], authoritative: true}), + terminalSupportsPrompting: vi.fn().mockReturnValue(false), + waitForStoreAuthCode: vi.fn(), + exchangeStoreAuthCodeForToken: vi.fn(), + }, + ) + + expect(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) + expect(presenter.success).toHaveBeenCalledWith(result) + }) + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 8856623f96..01546bb637 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -1,14 +1,15 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' -import {setStoredStoreAppSession} from './session-store.js' +import {setStoredStoreAppSession, type StoredStoreAppSession} from './session-store.js' import {exchangeStoreAuthCodeForToken} from './token-client.js' import {waitForStoreAuthCode} from './callback.js' import {createPkceBootstrap} from './pkce.js' import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' +import {loadStoredStoreSession} from './session-lifecycle.js' import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' -import {openURL} from '@shopify/cli-kit/node/system' +import {openURL, terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -24,6 +25,7 @@ interface StoreAuthDependencies { exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken resolveExistingScopes: (store: string) => Promise presenter: StoreAuthPresenter + terminalSupportsPrompting: typeof terminalSupportsPrompting } const defaultStoreAuthDependencies: StoreAuthDependencies = { @@ -32,45 +34,31 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, presenter: createStoreAuthPresenter('text'), + terminalSupportsPrompting, } -export async function authenticateStoreWithApp( - input: StoreAuthInput, - dependencies: Partial = {}, -): Promise { - const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} - const store = normalizeStoreFqdn(input.store) - await recordStoreFqdnMetadata(store, false) - const requestedScopes = parseStoreAuthScopes(input.scopes) - const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) - const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) - const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes - - if (existingScopeResolution.scopes.length > 0) { - outputDebug( - outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, - ) - } - - const bootstrap = createPkceBootstrap({ - store, +function storedSessionToStoreAuthResult( + session: StoredStoreAppSession, + scopes: string[], + acquiredAt = session.acquiredAt, +): StoreAuthResult { + return { + store: session.store, + userId: session.userId, scopes, - exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, - }) - const { - authorization: {authorizationUrl}, - } = bootstrap - - resolvedDependencies.presenter.openingBrowser() + acquiredAt, + expiresAt: session.expiresAt, + refreshTokenExpiresAt: session.refreshTokenExpiresAt, + hasRefreshToken: Boolean(session.refreshToken), + associatedUser: session.associatedUser, + } +} - const code = await resolvedDependencies.waitForStoreAuthCode({ - ...bootstrap.waitForAuthCodeOptions, - onListening: async () => { - const opened = await resolvedDependencies.openURL(authorizationUrl) - if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) - }, - }) - const tokenResponse = await bootstrap.exchangeCodeForToken(code) +async function persistStoreAuthToken( + tokenResponse: Awaited>, + store: string, + validationScopes: string[], +): Promise { await recordStoreFqdnMetadata(store, true) const userId = tokenResponse.associated_user?.id?.toString() @@ -81,7 +69,6 @@ export async function authenticateStoreWithApp( const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - const result: StoreAuthResult = { store, userId, @@ -120,6 +107,61 @@ export async function authenticateStoreWithApp( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) + return result +} + +export async function authenticateStoreWithApp( + input: StoreAuthInput, + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} + const store = normalizeStoreFqdn(input.store) + await recordStoreFqdnMetadata(store, false) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({ + store, + scopes, + exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, + }) + const { + authorization: {authorizationUrl}, + } = bootstrap + + if (!resolvedDependencies.terminalSupportsPrompting()) { + const existingMergedScopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + if ( + existingScopeResolution.authoritative && + existingMergedScopes.length === existingScopeResolution.scopes.length && + existingMergedScopes.every((scope) => existingScopeResolution.scopes.includes(scope)) + ) { + const session = await loadStoredStoreSession(store) + const result = storedSessionToStoreAuthResult(session, existingScopeResolution.scopes) + resolvedDependencies.presenter.success(result) + return result + } + } + + resolvedDependencies.presenter.openingBrowser() + + const code = await resolvedDependencies.waitForStoreAuthCode({ + ...bootstrap.waitForAuthCodeOptions, + onListening: async () => { + const opened = await resolvedDependencies.openURL(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + }, + }) + const result = await persistStoreAuthToken(await bootstrap.exchangeCodeForToken(code), store, validationScopes) + resolvedDependencies.presenter.success(result) return result } diff --git a/packages/store/src/cli/services/store/auth/result.test.ts b/packages/store/src/cli/services/store/auth/result.test.ts index 29e11516d7..4f21a05237 100644 --- a/packages/store/src/cli/services/store/auth/result.test.ts +++ b/packages/store/src/cli/services/store/auth/result.test.ts @@ -1,41 +1,12 @@ import {createStoreAuthPresenter} from './result.js' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {beforeEach, describe, expect, test} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -function captureStandardStreams() { - const stdout: string[] = [] - const stderr: string[] = [] - - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - return true - }) as typeof process.stdout.write) - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - return true - }) as typeof process.stderr.write) - - return { - stdout: () => stdout.join(''), - stderr: () => stderr.join(''), - restore: () => { - stdoutSpy.mockRestore() - stderrSpy.mockRestore() - }, - } -} - describe('store auth presenter', () => { - const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST - beforeEach(() => { mockAndCaptureOutput().clear() }) - afterEach(() => { - process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv - }) - test('renders human success output in text mode', () => { const output = mockAndCaptureOutput() const presenter = createStoreAuthPresenter('text') @@ -75,30 +46,26 @@ describe('store auth presenter', () => { expect(output.info()).not.toContain('shopify store execute') }) - test('writes browser guidance to stderr and json success to stdout', () => { - process.env.SHOPIFY_UNIT_TEST = 'false' - const streams = captureStandardStreams() + test('writes browser guidance and json success output', () => { + const output = mockAndCaptureOutput() const presenter = createStoreAuthPresenter('json') - try { - presenter.openingBrowser() - presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') - presenter.success({ - store: 'shop.myshopify.com', - userId: '42', - scopes: ['read_products'], - acquiredAt: '2026-04-02T00:00:00.000Z', - hasRefreshToken: true, - associatedUser: {id: 42, email: 'merchant@example.com'}, - }) - } finally { - streams.restore() - } + presenter.openingBrowser() + presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) - expect(streams.stderr()).toContain('Shopify CLI will open the app authorization page in your browser.') - expect(streams.stderr()).toContain('Browser did not open automatically. Open this URL manually:') - expect(streams.stderr()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') - expect(streams.stdout()).toContain('"store": "shop.myshopify.com"') - expect(streams.stdout()).not.toContain('Authenticated') + expect(output.info()).toContain('Shopify CLI will open the app authorization page in your browser.') + expect(output.info()).toContain('Keep this command running until authentication completes in the browser.') + expect(output.info()).toContain('Browser did not open automatically. Open this URL manually:') + expect(output.info()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(output.output()).not.toContain('Authenticated') }) }) diff --git a/packages/store/src/cli/services/store/auth/result.ts b/packages/store/src/cli/services/store/auth/result.ts index 58098a7c4f..50831e3e09 100644 --- a/packages/store/src/cli/services/store/auth/result.ts +++ b/packages/store/src/cli/services/store/auth/result.ts @@ -44,12 +44,14 @@ function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[ function displayStoreAuthOpeningBrowser(): void { outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('Keep this command running until authentication completes in the browser.') outputInfo('') } function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { outputInfo('Browser did not open automatically. Open this URL manually:') outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('Keep this command running until authentication completes in the browser.') outputInfo('') } diff --git a/packages/store/src/cli/services/store/auth/session-store.test.ts b/packages/store/src/cli/services/store/auth/session-store.test.ts index 523060f597..a7e29f4602 100644 --- a/packages/store/src/cli/services/store/auth/session-store.test.ts +++ b/packages/store/src/cli/services/store/auth/session-store.test.ts @@ -166,4 +166,5 @@ describe('store session storage', () => { expect(() => clearStoredStoreAppSession('shop.myshopify.com', '42', storage as any)).not.toThrow() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) + })